oficina / meet Public

schema: persons, meetings, links, grants, pending joins

The five tables of spec 2.1 replace the scaffold's notes; sessions and
auth schemas apply first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3f7e4c7b6634da62280712e21e6c921f120bd4e2 parent aed8cae
6 files changed, +145 −27
  • go.mod +1 −0
  • internal/meet/migrations.go +5 −11
  • internal/meet/migrations/0001_init.sql +23 −1
  • internal/meet/migrations/schema.sql +23 −1
  • internal/meet/models.go +70 −14
  • internal/meettest/schema_test.go +23 −0
diff --git a/go.mod b/go.mod
index 55fdfa9..5fda04c 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+ github.com/keymaildev/signin v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
diff --git a/internal/meet/migrations.go b/internal/meet/migrations.go
index 317ac24..a926123 100644
--- a/internal/meet/migrations.go
+++ b/internal/meet/migrations.go
@@ -3,7 +3,9 @@ package meet
import (
"embed"
+ rauth "amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
)
//go:embed migrations/*.sql
@@ -29,14 +31,6 @@ var Schema = migrate.MustFromFS(migrationFS, "meet")
// the same reason Schema must stay narrow: generate and check would
// misread a subsystem's tables as ones Models forgot.
//
-// A fresh scaffold uses no subsystem yet, so today this is just
-// Schema. Adding one (say, sessions) means editing this line, not
-// Schema:
-//
-// var BootSchema = migrate.Merge(sessions.Schema, Schema)
-//
-// Merge's argument order is apply order, so a subsystem whose
-// migrations another one reads (auth after sessions) goes first.
-// Get this line wrong and either boot silently skips a subsystem's
-// tables, or generate/check try to drop them.
-var BootSchema = migrate.Merge(Schema)
+// Apply order: sessions before auth (auth's second migration reads the
+// sessions table), Meet's own last.
+var BootSchema = migrate.Merge(sessions.Schema, rauth.Schema, Schema)
diff --git a/internal/meet/migrations/0001_init.sql b/internal/meet/migrations/0001_init.sql
index c63a80d..bbba508 100644
--- a/internal/meet/migrations/0001_init.sql
+++ b/internal/meet/migrations/0001_init.sql
@@ -1,2 +1,24 @@
-CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime);
+CREATE TABLE `persons` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text NOT NULL,`name` text NOT NULL,`created_at` datetime);
+
+CREATE UNIQUE INDEX `idx_persons_email` ON `persons`(`email`);
+
+CREATE TABLE `meetings` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text NOT NULL,`title_sealed` blob,`starts_at` datetime,`created_by` text NOT NULL,`created_at` datetime,`ended_at` datetime,`ended_by` text);
+
+CREATE UNIQUE INDEX `idx_meetings_sqid` ON `meetings`(`sqid`);
+
+CREATE TABLE `meeting_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`token_hash` text NOT NULL,`created_by` text NOT NULL,`created_at` datetime,`revoked_at` datetime);
+
+CREATE UNIQUE INDEX `idx_meeting_links_token_hash` ON `meeting_links`(`token_hash`);
+
+CREATE INDEX `idx_meeting_links_meeting_id` ON `meeting_links`(`meeting_id`);
+
+CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text);
+
+CREATE INDEX `idx_guest_grants_expires_at` ON `guest_grants`(`expires_at`);
+
+CREATE UNIQUE INDEX `idx_grant_meeting_person` ON `guest_grants`(`meeting_id`,`person_id`);
+
+CREATE TABLE `pending_joins` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`link_id` integer NOT NULL,`address_fingerprint` text NOT NULL,`created_at` datetime,`expires_at` datetime);
+
+CREATE INDEX `idx_pending_joins_address_fingerprint` ON `pending_joins`(`address_fingerprint`);
diff --git a/internal/meet/migrations/schema.sql b/internal/meet/migrations/schema.sql
index 239e296..b940601 100644
--- a/internal/meet/migrations/schema.sql
+++ b/internal/meet/migrations/schema.sql
@@ -1,5 +1,27 @@
-- Generated by rastrillo migration generate; DO NOT EDIT.
-- The schema every migration in this directory adds up to.
-CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime);
+CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text);
+
+CREATE TABLE `meeting_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`token_hash` text NOT NULL,`created_by` text NOT NULL,`created_at` datetime,`revoked_at` datetime);
+
+CREATE TABLE `meetings` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text NOT NULL,`title_sealed` blob,`starts_at` datetime,`created_by` text NOT NULL,`created_at` datetime,`ended_at` datetime,`ended_by` text);
+
+CREATE TABLE `pending_joins` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`link_id` integer NOT NULL,`address_fingerprint` text NOT NULL,`created_at` datetime,`expires_at` datetime);
+
+CREATE TABLE `persons` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text NOT NULL,`name` text NOT NULL,`created_at` datetime);
+
+CREATE UNIQUE INDEX `idx_grant_meeting_person` ON `guest_grants`(`meeting_id`,`person_id`);
+
+CREATE INDEX `idx_guest_grants_expires_at` ON `guest_grants`(`expires_at`);
+
+CREATE INDEX `idx_meeting_links_meeting_id` ON `meeting_links`(`meeting_id`);
+
+CREATE UNIQUE INDEX `idx_meeting_links_token_hash` ON `meeting_links`(`token_hash`);
+
+CREATE UNIQUE INDEX `idx_meetings_sqid` ON `meetings`(`sqid`);
+
+CREATE INDEX `idx_pending_joins_address_fingerprint` ON `pending_joins`(`address_fingerprint`);
+
+CREATE UNIQUE INDEX `idx_persons_email` ON `persons`(`email`);
diff --git a/internal/meet/models.go b/internal/meet/models.go
index fa93f88..c4a188e 100644
--- a/internal/meet/models.go
+++ b/internal/meet/models.go
@@ -3,23 +3,79 @@ package meet
import "time"
// Models is every model the schema generator manages. Keep it in step
-// with the structs below: `rastrillo migration generate` reads
-// it to work out what the database should look like, and
-// `rastrillo migration check` fails CI when it and the
-// migrations disagree.
+// with the structs below: `rastrillo migration generate` reads it, and
+// `rastrillo migration check` fails CI when it and the migrations
+// disagree.
var Models = []any{
- &Note{},
+ &Person{},
+ &Meeting{},
+ &MeetingLink{},
+ &GuestGrant{},
+ &PendingJoin{},
}
-// Note is a starting example model — rename it, replace it, add more
-// beside it. Whatever it becomes, the rule stays the same: every query
-// touching user-owned rows goes through scope.Owned (SKILL.md) — reads
-// AND writes — and a request body is never bound onto a model directly
-// (explicit map[string]any + .Select allowlist).
-type Note struct {
+// Person is a signed-in address. Standing (member, guest, none) is
+// never a column; it is computed per request (spec §2.3).
+type Person struct {
ID int64
- Title string
- Body string
+ Email string `gorm:"uniqueIndex;not null"`
+ Name string `gorm:"not null"`
CreatedAt time.Time
- UpdatedAt time.Time
+}
+
+// TableName overrides GORM's default pluralization. GORM treats
+// "person" as an irregular noun (via jinzhu/inflection) and would
+// otherwise name this table "people"; the spec and this app's schema
+// test both name it "persons".
+func (Person) TableName() string { return "persons" }
+
+// Meeting is a room that may be entered. Its title is sealed; its
+// times are plaintext because the list sorts on them.
+type Meeting struct {
+ ID int64
+ Sqid string `gorm:"uniqueIndex;not null"`
+ TitleSealed []byte `gorm:"column:title_sealed"`
+ StartsAt *time.Time
+ CreatedBy string `gorm:"not null"`
+ CreatedAt time.Time
+ EndedAt *time.Time
+ EndedBy string
+}
+
+// MeetingLink holds the SHA-256 of a token that was shown once
+// (ruling 16). Several may exist per meeting so a revoked one can be
+// replaced.
+type MeetingLink struct {
+ ID int64
+ MeetingID int64 `gorm:"index;not null"`
+ TokenHash string `gorm:"uniqueIndex;not null"`
+ CreatedBy string `gorm:"not null"`
+ CreatedAt time.Time
+ RevokedAt *time.Time
+}
+
+// GuestGrant is a non-member's entry authority for one meeting. One
+// row per person per meeting. AdmittedAt and AdmittedBy are an audit of
+// the first admission (slice 2) and confer nothing.
+type GuestGrant struct {
+ ID int64
+ MeetingID int64 `gorm:"uniqueIndex:idx_grant_meeting_person;not null"`
+ PersonID int64 `gorm:"uniqueIndex:idx_grant_meeting_person;not null"`
+ GrantedAt time.Time
+ ExpiresAt time.Time `gorm:"index"`
+ AdmittedAt *time.Time
+ AdmittedBy string
+}
+
+// PendingJoin is written before a magic link is mailed to someone who
+// followed a live meeting link, so the emailed link can admit them in
+// any browser and /continue can find their destination. The address is
+// a keyed fingerprint, never plaintext.
+type PendingJoin struct {
+ ID int64
+ MeetingID int64 `gorm:"not null"`
+ LinkID int64 `gorm:"not null"`
+ AddressFingerprint string `gorm:"index;not null"`
+ CreatedAt time.Time
+ ExpiresAt time.Time
}
diff --git a/internal/meettest/schema_test.go b/internal/meettest/schema_test.go
new file mode 100644
index 0000000..52fa6b7
--- /dev/null
+++ b/internal/meettest/schema_test.go
@@ -0,0 +1,23 @@
+package meettest
+
+import (
+ "testing"
+
+ "amadan.net/oficina/meet/internal/meet"
+)
+
+func TestBootCreatesEveryTable(t *testing.T) {
+ d := openDB(t)
+ if _, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment}); err != nil {
+ t.Fatal(err)
+ }
+ for _, table := range []string{"persons", "meetings", "meeting_links", "guest_grants", "pending_joins", "sessions", "auth_links"} {
+ var n int
+ if err := d.G.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&n).Error; err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Errorf("table %s missing after boot", table)
+ }
+ }
+}