| 1 | package idear |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // Member is a row in the roster: one person, at one role, in one |
| 6 | // instance. |
| 7 | // |
| 8 | // Subject is the join to the app's own identity: idear does not own |
| 9 | // email and password, and Subject is the only identifier both the |
| 10 | // password and keymail identity plugins produce (see |
| 11 | // docs/superpowers/specs/2026-08-23-idear-design.md §4). Email and |
| 12 | // Name are a display cache, not the source of truth for who someone |
| 13 | // is. |
| 14 | // |
| 15 | // Removal is DeactivatedAt, never a delete — see Active. |
| 16 | type Member struct { |
| 17 | ID int64 |
| 18 | Subject string `gorm:"uniqueIndex"` |
| 19 | Email string `gorm:"index"` |
| 20 | Name string |
| 21 | Role Role `gorm:"not null;index"` |
| 22 | DeactivatedAt *time.Time |
| 23 | CreatedAt time.Time |
| 24 | UpdatedAt time.Time |
| 25 | } |
| 26 | |
| 27 | // Active reports whether m exists and has not been deactivated. A nil |
| 28 | // Member is never active, so callers can pass a lookup's zero value |
| 29 | // straight in without a separate nil check. |
| 30 | func (m *Member) Active() bool { |
| 31 | return m != nil && m.DeactivatedAt == nil |
| 32 | } |
| 33 | |
| 34 | // TableName namespaces the table as idear_members, not GORM's default |
| 35 | // members, so it cannot collide with an app's own table of that name. |
| 36 | func (Member) TableName() string { return "idear_members" } |
| 37 | |
| 38 | // Invitation is an offer to join the roster at a given Role, redeemed |
| 39 | // by the holder of the plaintext token whose SHA-256 digest is stored |
| 40 | // in TokenHash. Only the digest is ever persisted — see token.go — |
| 41 | // matching sessions, which holds nothing but token digests either. |
| 42 | type Invitation struct { |
| 43 | ID int64 |
| 44 | Email string `gorm:"index"` |
| 45 | Role Role `gorm:"not null"` |
| 46 | TokenHash string `gorm:"uniqueIndex"` |
| 47 | InvitedBy int64 |
| 48 | CreatedAt time.Time |
| 49 | ExpiresAt time.Time |
| 50 | AcceptedAt *time.Time |
| 51 | RevokedAt *time.Time |
| 52 | } |
| 53 | |
| 54 | // Pending reports whether i can still be redeemed as of now: not yet |
| 55 | // accepted, not revoked, and not expired. All three must hold — a |
| 56 | // single check standing for the other two would let a revoked or |
| 57 | // expired invitation read as usable the moment the case that also |
| 58 | // disqualifies it stops being exercised. |
| 59 | func (i *Invitation) Pending(now time.Time) bool { |
| 60 | return i.AcceptedAt == nil && i.RevokedAt == nil && i.ExpiresAt.After(now) |
| 61 | } |
| 62 | |
| 63 | // TableName namespaces the table as idear_invitations, not GORM's |
| 64 | // default invitations, so it cannot collide with an app's own table |
| 65 | // of that name. |
| 66 | func (Invitation) TableName() string { return "idear_invitations" } |
| 67 | |