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>
6 files changed,
+145
−27
go.mod+1 −0internal/meet/migrations.go+5 −11internal/meet/migrations/0001_init.sql+23 −1internal/meet/migrations/schema.sql+23 −1internal/meet/models.go+70 −14internal/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) |
| + } |
| + } |
| +} |