@nexpress/core 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/{audit-54XLVCWD.js → audit-43OLHR3U.js} +4 -4
  2. package/dist/auth.d.ts +2 -2
  3. package/dist/auth.js +6 -6
  4. package/dist/{can-YLUHRJAB.js → can-FKIEV54H.js} +5 -5
  5. package/dist/{chunk-TFJ4MKPH.js → chunk-26RYBFTF.js} +2 -2
  6. package/dist/{chunk-PPAS4SZR.js → chunk-2KNG5KMM.js} +2 -2
  7. package/dist/{chunk-5L2FVFUX.js → chunk-5LCLS6VE.js} +18 -18
  8. package/dist/{chunk-PPBWRKO2.js → chunk-74CGJJDY.js} +3 -3
  9. package/dist/{chunk-HKAYX4B4.js → chunk-7GNVXRLG.js} +6 -6
  10. package/dist/{chunk-2YDGE7YX.js → chunk-B7DTNT4O.js} +2 -2
  11. package/dist/{chunk-CTSQ7BRI.js → chunk-CAS4Z6IN.js} +2 -2
  12. package/dist/{chunk-BHK3AD3Q.js → chunk-CHQJG4BB.js} +3 -3
  13. package/dist/{chunk-NUCGHWCF.js → chunk-CKT4QZDC.js} +7 -7
  14. package/dist/{chunk-4IEBNUYU.js → chunk-CTUHJHLH.js} +4 -4
  15. package/dist/{chunk-473S4TER.js → chunk-DWG3RZH2.js} +4 -4
  16. package/dist/{chunk-QVJ2HCAX.js → chunk-ELK6AVW5.js} +4 -4
  17. package/dist/{chunk-UGQSQO5B.js → chunk-HM46WM45.js} +9 -9
  18. package/dist/{chunk-YFJK2YEJ.js → chunk-KSUS4UNN.js} +3 -3
  19. package/dist/{chunk-FNLBIPKV.js → chunk-L4F5RAQ5.js} +11 -11
  20. package/dist/{chunk-THX3SHYA.js → chunk-L6VG7IK6.js} +3 -3
  21. package/dist/{chunk-VNIHXQ7W.js → chunk-LN6NTH6E.js} +5 -5
  22. package/dist/{chunk-RIPHIRPP.js → chunk-ML2E3P3X.js} +3 -3
  23. package/dist/{chunk-JJL74ZPK.js → chunk-NFHS7CFV.js} +2 -2
  24. package/dist/{chunk-55FU6WED.js → chunk-PQBJWZ7D.js} +6 -6
  25. package/dist/{chunk-DK2JBJH7.js → chunk-QBIJZZ5V.js} +3 -3
  26. package/dist/{chunk-OK5HOCQI.js → chunk-QYP6E5FP.js} +4 -4
  27. package/dist/{chunk-GFXXRMH4.js → chunk-S37WWNBB.js} +29 -29
  28. package/dist/{chunk-S27S42QY.js → chunk-TETTWT56.js} +2 -2
  29. package/dist/{chunk-FZ7O6DWI.js → chunk-U4QCCLAW.js} +2 -2
  30. package/dist/{chunk-M43PGOQY.js → chunk-X7K5F2UI.js} +474 -474
  31. package/dist/chunk-X7K5F2UI.js.map +1 -0
  32. package/dist/community.d.ts +1 -1
  33. package/dist/community.js +17 -17
  34. package/dist/{config-XIWZFOFI.js → config-65OBL4YH.js} +10 -10
  35. package/dist/db-schema.d.ts +2 -2
  36. package/dist/db-schema.js +2 -2
  37. package/dist/db.d.ts +3 -3
  38. package/dist/db.js +3 -3
  39. package/dist/{digest-SY42GQSU.js → digest-ZODDTXA2.js} +6 -6
  40. package/dist/{host-SOCAJIUE.js → host-55D6RX3U.js} +7 -7
  41. package/dist/i18n.d.ts +1 -1
  42. package/dist/i18n.js +5 -5
  43. package/dist/{index-XwP1ET8b.d.ts → index-BWsQUGRZ.d.ts} +2 -2
  44. package/dist/{index-B6-_vr_m.d.ts → index-BpW3PGhP.d.ts} +1 -1
  45. package/dist/{index-CY55LC0u.d.ts → index-Ccw0AkXh.d.ts} +3 -3
  46. package/dist/{index-CeiTvwbp.d.ts → index-D6Q7DOl7.d.ts} +1 -1
  47. package/dist/index.d.ts +34 -8
  48. package/dist/index.js +225 -33
  49. package/dist/index.js.map +1 -1
  50. package/dist/{job-log-VZXWQUDK.js → job-log-N3IGI4NA.js} +4 -4
  51. package/dist/jobs.d.ts +2 -2
  52. package/dist/jobs.js +4 -4
  53. package/dist/{logger-S7REWDNE.js → logger-2WUTTELV.js} +2 -2
  54. package/dist/media.d.ts +2 -2
  55. package/dist/media.js +5 -5
  56. package/dist/{mentions-2IHFVSHW.js → mentions-NCQR4B72.js} +5 -5
  57. package/dist/{mutes-EWAE5FZR.js → mutes-FJSSU2JP.js} +5 -5
  58. package/dist/{notification-prefs-VPJDU7I6.js → notification-prefs-H4HFVCL7.js} +3 -3
  59. package/dist/observability.js +2 -2
  60. package/dist/{registry-XIXDEPVI.js → registry-WZVL5HH6.js} +3 -3
  61. package/dist/reputation-ICIXDGPM.js +11 -0
  62. package/dist/{scheduled-W5PR7C6H.js → scheduled-UC7O2HBQ.js} +8 -8
  63. package/dist/seo.js +7 -7
  64. package/dist/{settings-FOBIESPB.js → settings-JODDWMDB.js} +3 -3
  65. package/dist/{strings-VAE47B2C.js → strings-4EWJYDOG.js} +6 -6
  66. package/dist/{types-TlsbXS0T.d.ts → types-C-r01wmU.d.ts} +25 -16
  67. package/package.json +1 -1
  68. package/dist/chunk-M43PGOQY.js.map +0 -1
  69. package/dist/reputation-JRL2YQHM.js +0 -11
  70. /package/dist/{audit-54XLVCWD.js.map → audit-43OLHR3U.js.map} +0 -0
  71. /package/dist/{can-YLUHRJAB.js.map → can-FKIEV54H.js.map} +0 -0
  72. /package/dist/{chunk-TFJ4MKPH.js.map → chunk-26RYBFTF.js.map} +0 -0
  73. /package/dist/{chunk-PPAS4SZR.js.map → chunk-2KNG5KMM.js.map} +0 -0
  74. /package/dist/{chunk-5L2FVFUX.js.map → chunk-5LCLS6VE.js.map} +0 -0
  75. /package/dist/{chunk-PPBWRKO2.js.map → chunk-74CGJJDY.js.map} +0 -0
  76. /package/dist/{chunk-HKAYX4B4.js.map → chunk-7GNVXRLG.js.map} +0 -0
  77. /package/dist/{chunk-2YDGE7YX.js.map → chunk-B7DTNT4O.js.map} +0 -0
  78. /package/dist/{chunk-CTSQ7BRI.js.map → chunk-CAS4Z6IN.js.map} +0 -0
  79. /package/dist/{chunk-BHK3AD3Q.js.map → chunk-CHQJG4BB.js.map} +0 -0
  80. /package/dist/{chunk-NUCGHWCF.js.map → chunk-CKT4QZDC.js.map} +0 -0
  81. /package/dist/{chunk-4IEBNUYU.js.map → chunk-CTUHJHLH.js.map} +0 -0
  82. /package/dist/{chunk-473S4TER.js.map → chunk-DWG3RZH2.js.map} +0 -0
  83. /package/dist/{chunk-QVJ2HCAX.js.map → chunk-ELK6AVW5.js.map} +0 -0
  84. /package/dist/{chunk-UGQSQO5B.js.map → chunk-HM46WM45.js.map} +0 -0
  85. /package/dist/{chunk-YFJK2YEJ.js.map → chunk-KSUS4UNN.js.map} +0 -0
  86. /package/dist/{chunk-FNLBIPKV.js.map → chunk-L4F5RAQ5.js.map} +0 -0
  87. /package/dist/{chunk-THX3SHYA.js.map → chunk-L6VG7IK6.js.map} +0 -0
  88. /package/dist/{chunk-VNIHXQ7W.js.map → chunk-LN6NTH6E.js.map} +0 -0
  89. /package/dist/{chunk-RIPHIRPP.js.map → chunk-ML2E3P3X.js.map} +0 -0
  90. /package/dist/{chunk-JJL74ZPK.js.map → chunk-NFHS7CFV.js.map} +0 -0
  91. /package/dist/{chunk-55FU6WED.js.map → chunk-PQBJWZ7D.js.map} +0 -0
  92. /package/dist/{chunk-DK2JBJH7.js.map → chunk-QBIJZZ5V.js.map} +0 -0
  93. /package/dist/{chunk-OK5HOCQI.js.map → chunk-QYP6E5FP.js.map} +0 -0
  94. /package/dist/{chunk-GFXXRMH4.js.map → chunk-S37WWNBB.js.map} +0 -0
  95. /package/dist/{chunk-S27S42QY.js.map → chunk-TETTWT56.js.map} +0 -0
  96. /package/dist/{chunk-FZ7O6DWI.js.map → chunk-U4QCCLAW.js.map} +0 -0
  97. /package/dist/{config-XIWZFOFI.js.map → config-65OBL4YH.js.map} +0 -0
  98. /package/dist/{digest-SY42GQSU.js.map → digest-ZODDTXA2.js.map} +0 -0
  99. /package/dist/{host-SOCAJIUE.js.map → host-55D6RX3U.js.map} +0 -0
  100. /package/dist/{job-log-VZXWQUDK.js.map → job-log-N3IGI4NA.js.map} +0 -0
  101. /package/dist/{logger-S7REWDNE.js.map → logger-2WUTTELV.js.map} +0 -0
  102. /package/dist/{mentions-2IHFVSHW.js.map → mentions-NCQR4B72.js.map} +0 -0
  103. /package/dist/{mutes-EWAE5FZR.js.map → mutes-FJSSU2JP.js.map} +0 -0
  104. /package/dist/{notification-prefs-VPJDU7I6.js.map → notification-prefs-H4HFVCL7.js.map} +0 -0
  105. /package/dist/{registry-XIXDEPVI.js.map → registry-WZVL5HH6.js.map} +0 -0
  106. /package/dist/{reputation-JRL2YQHM.js.map → reputation-ICIXDGPM.js.map} +0 -0
  107. /package/dist/{scheduled-W5PR7C6H.js.map → scheduled-UC7O2HBQ.js.map} +0 -0
  108. /package/dist/{settings-FOBIESPB.js.map → settings-JODDWMDB.js.map} +0 -0
  109. /package/dist/{strings-VAE47B2C.js.map → strings-4EWJYDOG.js.map} +0 -0
@@ -1,4 +1,4 @@
1
- // src/db/schema/system.ts
1
+ // src/db/schema/community.ts
2
2
  import {
3
3
  boolean as boolean2,
4
4
  index as index3,
@@ -26,7 +26,7 @@ import {
26
26
  uuid as uuid2
27
27
  } from "drizzle-orm/pg-core";
28
28
 
29
- // src/db/schema/community.ts
29
+ // src/db/schema/system.ts
30
30
  import {
31
31
  boolean,
32
32
  index,
@@ -40,336 +40,228 @@ import {
40
40
  unique,
41
41
  uuid
42
42
  } from "drizzle-orm/pg-core";
43
- var npMemberStatusEnum = pgEnum("np_member_status", [
44
- "active",
45
- "pending",
46
- "suspended",
47
- "deleted",
48
- "imported"
49
- ]);
50
- var npBanScopeEnum = pgEnum("np_ban_scope", ["site", "category", "collection"]);
51
- var npBanKindEnum = pgEnum("np_ban_kind", ["temporary", "permanent"]);
52
- var npCommentStatusEnum = pgEnum("np_comment_status", [
53
- "visible",
54
- "pending",
55
- "hidden",
56
- "deleted"
43
+ var npUserRoleEnum = pgEnum("np_user_role", [
44
+ "admin",
45
+ "editor",
46
+ // 9.5: community moderator. Sits OUTSIDE the linear content-edit
47
+ // hierarchy — a moderator handles community moderation (hide
48
+ // comments, resolve reports, issue bans) but cannot author or edit
49
+ // collection content. ROLE_HIERARCHY in config/types.ts intentionally
50
+ // does not list this role; community-moderation paths check the role
51
+ // explicitly via `principalCan()`.
52
+ "moderator",
53
+ "author",
54
+ "viewer"
57
55
  ]);
58
- var npMemberRoleScopeEnum = pgEnum("np_member_role_scope", [
59
- "site",
60
- "category",
61
- "collection",
62
- "thread"
56
+ var npRevisionStatusEnum = pgEnum("np_revision_status", [
57
+ "draft",
58
+ "published",
59
+ "autosave"
63
60
  ]);
64
- var npMembers = pgTable(
65
- "np_members",
61
+ var npPasswordResetPurposeEnum = pgEnum("np_password_reset_purpose", ["invite", "reset"]);
62
+ var npUsers = pgTable("np_users", {
63
+ id: uuid("id").defaultRandom().primaryKey(),
64
+ email: text("email").notNull().unique(),
65
+ password: text("password").notNull(),
66
+ name: text("name").notNull(),
67
+ role: npUserRoleEnum("role").notNull(),
68
+ /**
69
+ * Phase 15.5 — super-admin flag. Bypasses per-site membership
70
+ * checks; the super-admin can manage every site including
71
+ * creating / deleting tenants. The flag is independent of
72
+ * the per-site `role` enum (a super-admin still needs a
73
+ * `role` field for non-multi-site contexts; multi-site
74
+ * permissions check `is_super_admin OR site_membership`).
75
+ */
76
+ isSuperAdmin: boolean("is_super_admin").default(false).notNull(),
77
+ avatar: uuid("avatar").references(() => npMedia.id),
78
+ loginAttempts: integer("login_attempts").default(0).notNull(),
79
+ lockUntil: timestamp("lock_until", { withTimezone: true, mode: "date" }),
80
+ tokenVersion: integer("token_version").default(0).notNull(),
81
+ passwordResetTokenHash: text("password_reset_token_hash"),
82
+ passwordResetExpiresAt: timestamp("password_reset_expires_at", {
83
+ withTimezone: true,
84
+ mode: "date"
85
+ }),
86
+ passwordResetPurpose: npPasswordResetPurposeEnum("password_reset_purpose"),
87
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
88
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
89
+ });
90
+ var npSiteMemberships = pgTable(
91
+ "np_site_memberships",
66
92
  {
67
- id: uuid("id").defaultRandom().primaryKey(),
68
- handle: text("handle").notNull().unique(),
69
- email: text("email").notNull().unique(),
70
- emailVerified: boolean("email_verified").default(false).notNull(),
71
- /** Argon2 hash. Nullable so SSO-only members can exist without a password. */
72
- password: text("password"),
73
- displayName: text("display_name").notNull(),
74
- avatar: uuid("avatar").references(() => npMedia.id),
75
- bio: text("bio"),
76
- status: npMemberStatusEnum("status").default("pending").notNull(),
77
- reputation: integer("reputation").default(0).notNull(),
78
- loginAttempts: integer("login_attempts").default(0).notNull(),
79
- lockUntil: timestamp("lock_until", { withTimezone: true, mode: "date" }),
80
- /** Bumped to invalidate every issued JWT (logout-everywhere, password reset). */
81
- tokenVersion: integer("token_version").default(0).notNull(),
82
- passwordResetTokenHash: text("password_reset_token_hash"),
83
- passwordResetExpiresAt: timestamp("password_reset_expires_at", {
84
- withTimezone: true,
85
- mode: "date"
86
- }),
87
- emailVerifyTokenHash: text("email_verify_token_hash"),
88
- emailVerifyExpiresAt: timestamp("email_verify_expires_at", {
89
- withTimezone: true,
90
- mode: "date"
91
- }),
92
- /** Plugin-extensible bag — preferences, custom profile fields, etc. */
93
- meta: jsonb("meta").$type().default({}).notNull(),
94
- /**
95
- * Phase 16.3 — per-member notification preferences. Shape:
96
- * { disabled?: string[] } — kinds the member opted out of
97
- * { digest?: "off"|"daily"|"weekly" } — email digest cadence (16.4)
98
- * Empty default = every kind enabled, no email digest.
99
- */
100
- notificationPrefs: jsonb("notification_prefs").$type().default({}).notNull(),
93
+ siteId: text("site_id").notNull(),
94
+ userId: uuid("user_id").notNull().references(() => npUsers.id, { onDelete: "cascade" }),
95
+ role: npUserRoleEnum("role").notNull(),
101
96
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
102
97
  updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
103
98
  },
104
- (table) => [index("np_members_status_idx").on(table.status)]
99
+ (table) => [primaryKey({ columns: [table.siteId, table.userId] })]
105
100
  );
106
- var npMemberSessions = pgTable("np_member_sessions", {
107
- id: uuid("id").defaultRandom().primaryKey(),
108
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
109
- tokenHash: text("token_hash").notNull(),
110
- userAgent: text("user_agent"),
111
- ip: text("ip"),
112
- expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
113
- createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
114
- });
115
- var npMemberIdentities = pgTable(
116
- "np_member_identities",
101
+ var npUserOAuthIdentities = pgTable(
102
+ "np_user_oauth_identities",
117
103
  {
118
104
  id: uuid("id").defaultRandom().primaryKey(),
119
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
105
+ userId: uuid("user_id").notNull().references(() => npUsers.id, { onDelete: "cascade" }),
120
106
  provider: text("provider").notNull(),
121
- subject: text("subject").notNull(),
122
- email: text("email"),
107
+ providerUserId: text("provider_user_id").notNull(),
123
108
  /** Free-form per-provider metadata (avatar URL, scopes granted, etc.). */
124
109
  metadata: jsonb("metadata").$type().default({}).notNull(),
125
110
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
126
111
  updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
127
112
  },
128
- (table) => [
129
- unique("np_member_identities_provider_subject_uq").on(table.provider, table.subject),
130
- unique("np_member_identities_member_provider_uq").on(table.memberId, table.provider),
131
- index("np_member_identities_member_idx").on(table.memberId)
132
- ]
133
- );
134
- var npMemberRoles = pgTable(
135
- "np_member_roles",
136
- {
137
- id: uuid("id").defaultRandom().primaryKey(),
138
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
139
- role: text("role").notNull(),
140
- scopeType: npMemberRoleScopeEnum("scope_type").notNull(),
141
- /** Nullable for `scope_type='site'`. Otherwise an opaque string id. */
142
- scopeId: text("scope_id"),
143
- /**
144
- * Phase 18 — the tenant the grant applies on. For
145
- * `scope_type='site'` this column IS the site identifier
146
- * (`scope_id` stays null because site is the root scope).
147
- * For category / collection / thread grants, `site_id` says
148
- * which tenant's category/collection/thread this row
149
- * targets — the same slug exists on every site.
150
- */
151
- siteId: text("site_id").default("default").notNull(),
152
- grantedBy: uuid("granted_by").references(() => npUsers.id),
153
- grantedAt: timestamp("granted_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
154
- expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" })
155
- },
156
- (table) => [
157
- // Two indexes mirror the two access patterns: "what can this member
158
- // do?" (memberId scan) and "who mods this scope?" (scope scan).
159
- index("np_member_roles_member_idx").on(table.memberId),
160
- index("np_member_roles_scope_idx").on(table.scopeType, table.scopeId),
161
- index("np_member_roles_site_idx").on(table.siteId, table.memberId),
162
- // `scope_id` is null for site-wide grants. NULLS NOT
163
- // DISTINCT makes two null `scope_id`s collide so the
164
- // unique constraint enforces "one grant per (member, role,
165
- // scope, site)." `site_id` widens the key so the same
166
- // member can hold the same role on different tenants.
167
- unique("np_member_roles_grant_uq").on(table.memberId, table.role, table.scopeType, table.scopeId, table.siteId).nullsNotDistinct()
168
- ]
113
+ (table) => ({
114
+ providerSubjectUnique: unique("np_user_oauth_identities_provider_subject_unique").on(
115
+ table.provider,
116
+ table.providerUserId
117
+ ),
118
+ userProviderUnique: unique("np_user_oauth_identities_user_provider_unique").on(
119
+ table.userId,
120
+ table.provider
121
+ ),
122
+ userIdx: index("np_user_oauth_identities_user_idx").on(table.userId)
123
+ })
169
124
  );
170
- var npComments = pgTable(
171
- "np_comments",
125
+ var npSessions = pgTable("np_sessions", {
126
+ id: uuid("id").defaultRandom().primaryKey(),
127
+ userId: uuid("user_id").notNull().references(() => npUsers.id, { onDelete: "cascade" }),
128
+ tokenHash: text("token_hash").notNull(),
129
+ userAgent: text("user_agent"),
130
+ ip: text("ip"),
131
+ expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
132
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
133
+ });
134
+ var npRevisions = pgTable(
135
+ "np_revisions",
172
136
  {
173
137
  id: uuid("id").defaultRandom().primaryKey(),
174
- targetType: text("target_type").notNull(),
175
- targetId: uuid("target_id").notNull(),
176
- parentId: uuid("parent_id").references(() => npComments.id, {
177
- onDelete: "cascade"
178
- }),
179
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
180
- bodyMd: text("body_md").notNull(),
181
- bodyHtml: text("body_html").notNull(),
182
- status: npCommentStatusEnum("status").default("visible").notNull(),
183
- hiddenByUserId: uuid("hidden_by_user_id").references(() => npUsers.id),
184
- hiddenByMemberId: uuid("hidden_by_member_id").references(() => npMembers.id),
185
- hiddenReason: text("hidden_reason"),
186
- editedAt: timestamp("edited_at", { withTimezone: true, mode: "date" }),
187
- /**
188
- * Phase 18 — site this comment belongs to. Filled at insert
189
- * time from the target document's site (canonical) so a
190
- * forged request resolver can't smuggle a comment into the
191
- * wrong site. Defaults to `'default'` for legacy single-
192
- * tenant rows so the migration backfill is a no-op.
193
- */
194
- siteId: text("site_id").default("default").notNull(),
138
+ collection: text("collection").notNull(),
139
+ documentId: text("document_id").notNull(),
140
+ version: integer("version").notNull(),
141
+ status: npRevisionStatusEnum("status").notNull(),
142
+ snapshot: jsonb("snapshot").$type().notNull(),
143
+ changedFields: text("changed_fields").array().notNull(),
144
+ authorId: uuid("author_id").references(() => npUsers.id),
195
145
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
196
146
  },
197
- (table) => [
198
- index("np_comments_target_idx").on(table.targetType, table.targetId, table.createdAt),
199
- index("np_comments_member_idx").on(table.memberId, table.createdAt),
200
- index("np_comments_site_idx").on(table.siteId, table.createdAt)
201
- ]
147
+ (table) => ({
148
+ documentVersionUnique: unique("np_revisions_document_id_version_unique").on(
149
+ table.documentId,
150
+ table.version
151
+ ),
152
+ collectionIdx: index("np_revisions_collection_idx").on(table.collection),
153
+ documentIdIdx: index("np_revisions_document_id_idx").on(table.documentId)
154
+ })
202
155
  );
203
- var npReactions = pgTable(
204
- "np_reactions",
156
+ var npSettings = pgTable(
157
+ "np_settings",
205
158
  {
206
- id: uuid("id").defaultRandom().primaryKey(),
207
- targetType: text("target_type").notNull(),
208
- targetId: uuid("target_id").notNull(),
209
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
210
- kind: text("kind").notNull(),
211
- /** Phase 18 — site this reaction belongs to (derived from target). */
212
159
  siteId: text("site_id").default("default").notNull(),
213
- createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
160
+ key: text("key").notNull(),
161
+ value: jsonb("value").$type().notNull(),
162
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
163
+ updatedBy: uuid("updated_by").references(() => npUsers.id)
214
164
  },
215
- (table) => [
216
- index("np_reactions_target_idx").on(table.targetType, table.targetId),
217
- index("np_reactions_site_idx").on(table.siteId),
218
- unique("np_reactions_unique").on(table.targetType, table.targetId, table.memberId, table.kind)
219
- ]
165
+ (table) => [primaryKey({ columns: [table.siteId, table.key] })]
220
166
  );
221
- var npFollows = pgTable(
222
- "np_follows",
167
+ var npSlugHistory = pgTable(
168
+ "np_slug_history",
223
169
  {
224
170
  id: uuid("id").defaultRandom().primaryKey(),
225
- followerId: uuid("follower_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
226
- targetType: text("target_type").notNull(),
227
- targetId: text("target_id").notNull(),
228
- /**
229
- * Phase 18 — site the follow happened on. The same global
230
- * member can follow on multiple sites and each row scopes
231
- * to where the click happened (so site-scoped notifications
232
- * + activity feeds don't leak cross-tenant). The unique
233
- * key is widened to include site_id so the same follower
234
- * can have parallel follow rows under different tenants.
235
- */
236
171
  siteId: text("site_id").default("default").notNull(),
172
+ collection: text("collection").notNull(),
173
+ documentId: text("document_id").notNull(),
174
+ oldSlug: text("old_slug").notNull(),
175
+ newSlug: text("new_slug").notNull(),
237
176
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
238
177
  },
239
178
  (table) => [
240
- index("np_follows_target_idx").on(table.targetType, table.targetId),
241
- index("np_follows_site_idx").on(table.siteId),
242
- unique("np_follows_unique").on(
243
- table.followerId,
244
- table.targetType,
245
- table.targetId,
246
- table.siteId
247
- )
179
+ index("np_slug_history_lookup_idx").on(table.siteId, table.collection, table.oldSlug),
180
+ index("np_slug_history_doc_idx").on(table.siteId, table.collection, table.documentId)
248
181
  ]
249
182
  );
250
- var npMemberMutes = pgTable(
251
- "np_member_mutes",
183
+ var npNavigation = pgTable(
184
+ "np_navigation",
252
185
  {
253
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
254
- targetId: uuid("target_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
255
- /**
256
- * Phase 18 — site the mute applies to. A muter can choose
257
- * to silence someone on one tenant without affecting their
258
- * other tenants. PK is widened to include site_id so the
259
- * same `(member, target)` pair can hold parallel rows per
260
- * site.
261
- */
186
+ id: uuid("id").defaultRandom().primaryKey(),
262
187
  siteId: text("site_id").default("default").notNull(),
263
- createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
188
+ location: text("location").notNull(),
189
+ items: jsonb("items").$type().notNull(),
190
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
191
+ updatedBy: uuid("updated_by").references(() => npUsers.id)
264
192
  },
265
- (table) => [
266
- primaryKey({ columns: [table.memberId, table.targetId, table.siteId] }),
267
- index("np_member_mutes_target_idx").on(table.targetId)
268
- ]
193
+ (table) => [unique("np_navigation_site_location_idx").on(table.siteId, table.location)]
269
194
  );
270
- var npNotifications = pgTable(
271
- "np_notifications",
195
+ var npStringOverrides = pgTable(
196
+ "np_string_overrides",
272
197
  {
273
- id: uuid("id").defaultRandom().primaryKey(),
274
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
275
- kind: text("kind").notNull(),
276
- payload: jsonb("payload").$type().default({}).notNull(),
277
- readAt: timestamp("read_at", { withTimezone: true, mode: "date" }),
278
- /**
279
- * Phase 18 — site this notification belongs to. A member
280
- * who's active on multiple tenants gets one inbox per site
281
- * (the inbox API filters by current site) so cross-tenant
282
- * activity doesn't bleed into the wrong site's UI.
283
- */
284
198
  siteId: text("site_id").default("default").notNull(),
285
- createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
199
+ locale: text("locale").notNull(),
200
+ key: text("key").notNull(),
201
+ value: text("value"),
202
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
203
+ updatedBy: uuid("updated_by").references(() => npUsers.id)
286
204
  },
287
- (table) => [
288
- index("np_notifications_inbox_idx").on(table.memberId, table.readAt, table.createdAt),
289
- index("np_notifications_site_inbox_idx").on(table.siteId, table.memberId, table.readAt)
290
- ]
205
+ (table) => [primaryKey({ columns: [table.siteId, table.locale, table.key] })]
291
206
  );
292
- var npReports = pgTable(
293
- "np_reports",
207
+ var npSites = pgTable(
208
+ "np_sites",
294
209
  {
295
- id: uuid("id").defaultRandom().primaryKey(),
296
- reporterId: uuid("reporter_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
297
- targetType: text("target_type").notNull(),
298
- targetId: text("target_id").notNull(),
299
- reason: text("reason").notNull(),
300
- resolvedAt: timestamp("resolved_at", { withTimezone: true, mode: "date" }),
301
- resolvedByUserId: uuid("resolved_by_user_id").references(() => npUsers.id),
302
- resolvedByMemberId: uuid("resolved_by_member_id").references(() => npMembers.id),
303
- resolution: text("resolution"),
304
- /**
305
- * Phase 18 — site this report belongs to. The mod queue
306
- * is per-site so a category-mod on tenant A doesn't see
307
- * tenant B's reports.
308
- */
309
- siteId: text("site_id").default("default").notNull(),
310
- createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
210
+ id: text("id").primaryKey(),
211
+ name: text("name").notNull(),
212
+ hostname: text("hostname"),
213
+ description: text("description"),
214
+ settings: jsonb("settings").$type().default({}).notNull(),
215
+ isDefault: boolean("is_default").default(false).notNull(),
216
+ createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
217
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
311
218
  },
312
- (table) => [
313
- index("np_reports_queue_idx").on(table.resolvedAt, table.createdAt),
314
- index("np_reports_target_idx").on(table.targetType, table.targetId),
315
- index("np_reports_site_queue_idx").on(table.siteId, table.resolvedAt)
316
- ]
219
+ (table) => [unique("np_sites_hostname_idx").on(table.hostname)]
317
220
  );
318
- var npAuditEvents = pgTable(
319
- "np_audit_events",
221
+ var npPlugins = pgTable("np_plugins", {
222
+ id: text("id").primaryKey(),
223
+ enabled: boolean("enabled").default(true).notNull(),
224
+ installedAt: timestamp("installed_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
225
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
226
+ });
227
+ var NP_GLOBAL_PLUGIN_SITE_ID = "_global_";
228
+ var npPluginStorage = pgTable(
229
+ "np_plugin_storage",
320
230
  {
321
- id: uuid("id").defaultRandom().primaryKey(),
322
- actorKind: text("actor_kind").notNull(),
323
- actorUserId: uuid("actor_user_id").references(() => npUsers.id),
324
- actorMemberId: uuid("actor_member_id").references(() => npMembers.id),
325
- action: text("action").notNull(),
326
- targetType: text("target_type"),
327
- targetId: text("target_id"),
328
- payload: jsonb("payload").$type().default({}).notNull(),
329
- /**
330
- * Phase 17 — site-scoped audit. Filled by `recordAuditEvent`
331
- * from the current request's site (the multi-site resolver).
332
- * Nullable for events that don't belong to a single site
333
- * (super-admin actions, background jobs, scripts).
334
- */
335
- siteId: text("site_id"),
336
- createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
231
+ pluginId: text("plugin_id").notNull(),
232
+ siteId: text("site_id").default(NP_GLOBAL_PLUGIN_SITE_ID).notNull(),
233
+ key: text("key").notNull(),
234
+ value: jsonb("value").$type().notNull(),
235
+ expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }),
236
+ updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
337
237
  },
338
- (table) => [
339
- index("np_audit_target_idx").on(table.targetType, table.targetId, table.createdAt),
340
- index("np_audit_actor_user_idx").on(table.actorUserId, table.createdAt),
341
- index("np_audit_actor_member_idx").on(table.actorMemberId, table.createdAt),
342
- index("np_audit_site_idx").on(table.siteId, table.createdAt)
343
- ]
238
+ (table) => ({
239
+ pk: primaryKey({ columns: [table.pluginId, table.siteId, table.key] }),
240
+ pluginIdx: index("np_plugin_storage_plugin_id_idx").on(table.pluginId),
241
+ siteIdx: index("np_plugin_storage_site_idx").on(table.siteId)
242
+ })
344
243
  );
345
- var npBans = pgTable(
346
- "np_bans",
244
+ var npWorkerHeartbeats = pgTable("np_worker_heartbeats", {
245
+ id: text("id").primaryKey(),
246
+ status: text("status").default("running").notNull(),
247
+ startedAt: timestamp("started_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
248
+ lastSeenAt: timestamp("last_seen_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
249
+ /** Free-form metadata (worker version, hostname, env). */
250
+ meta: jsonb("meta").$type().default({}).notNull()
251
+ });
252
+ var npJobLogs = pgTable(
253
+ "np_job_logs",
347
254
  {
348
255
  id: uuid("id").defaultRandom().primaryKey(),
349
- memberId: uuid("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
350
- scopeType: npBanScopeEnum("scope_type").notNull(),
351
- scopeId: text("scope_id"),
352
- kind: npBanKindEnum("kind").notNull(),
353
- expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }),
354
- reason: text("reason"),
355
- byUserId: uuid("by_user_id").references(() => npUsers.id),
356
- byMemberId: uuid("by_member_id").references(() => npMembers.id),
357
- /**
358
- * Phase 18 — the tenant this ban applies to. Pre-Phase 18
359
- * `scope_type='site'` rows had `scope_id=null` because
360
- * "site" was the singular root scope; with multi-tenancy
361
- * the column tells `assertNotBanned` WHICH site the ban
362
- * blocks writes on. Category / collection scopes resolve
363
- * per-site too — the same `posts` collection slug exists
364
- * on every tenant.
365
- */
366
- siteId: text("site_id").default("default").notNull(),
256
+ jobId: text("job_id").notNull(),
257
+ level: text("level").notNull(),
258
+ message: text("message").notNull(),
259
+ context: jsonb("context").$type(),
367
260
  createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
368
261
  },
369
262
  (table) => [
370
- index("np_bans_member_scope_idx").on(table.memberId, table.scopeType, table.scopeId),
371
- index("np_bans_active_idx").on(table.memberId, table.expiresAt),
372
- index("np_bans_site_idx").on(table.siteId, table.memberId)
263
+ index("np_job_logs_job_idx").on(table.jobId, table.createdAt),
264
+ index("np_job_logs_created_idx").on(table.createdAt)
373
265
  ]
374
266
  );
375
267
 
@@ -445,233 +337,362 @@ var npMediaRefs = pgTable2(
445
337
  })
446
338
  );
447
339
 
448
- // src/db/schema/system.ts
449
- var npUserRoleEnum = pgEnum3("np_user_role", [
450
- "admin",
451
- "editor",
452
- // 9.5: community moderator. Sits OUTSIDE the linear content-edit
453
- // hierarchy — a moderator handles community moderation (hide
454
- // comments, resolve reports, issue bans) but cannot author or edit
455
- // collection content. ROLE_HIERARCHY in config/types.ts intentionally
456
- // does not list this role; community-moderation paths check the role
457
- // explicitly via `principalCan()`.
458
- "moderator",
459
- "author",
460
- "viewer"
340
+ // src/db/schema/community.ts
341
+ var npMemberStatusEnum = pgEnum3("np_member_status", [
342
+ "active",
343
+ "pending",
344
+ "suspended",
345
+ "deleted",
346
+ "imported"
461
347
  ]);
462
- var npRevisionStatusEnum = pgEnum3("np_revision_status", [
463
- "draft",
464
- "published",
465
- "autosave"
348
+ var npBanScopeEnum = pgEnum3("np_ban_scope", ["site", "category", "collection"]);
349
+ var npBanKindEnum = pgEnum3("np_ban_kind", ["temporary", "permanent"]);
350
+ var npCommentStatusEnum = pgEnum3("np_comment_status", [
351
+ "visible",
352
+ "pending",
353
+ "hidden",
354
+ "deleted"
466
355
  ]);
467
- var npPasswordResetPurposeEnum = pgEnum3("np_password_reset_purpose", ["invite", "reset"]);
468
- var npUsers = pgTable3("np_users", {
469
- id: uuid3("id").defaultRandom().primaryKey(),
470
- email: text3("email").notNull().unique(),
471
- password: text3("password").notNull(),
472
- name: text3("name").notNull(),
473
- role: npUserRoleEnum("role").notNull(),
474
- /**
475
- * Phase 15.5 — super-admin flag. Bypasses per-site membership
476
- * checks; the super-admin can manage every site including
477
- * creating / deleting tenants. The flag is independent of
478
- * the per-site `role` enum (a super-admin still needs a
479
- * `role` field for non-multi-site contexts; multi-site
480
- * permissions check `is_super_admin OR site_membership`).
481
- */
482
- isSuperAdmin: boolean2("is_super_admin").default(false).notNull(),
483
- avatar: uuid3("avatar").references(() => npMedia.id),
484
- loginAttempts: integer3("login_attempts").default(0).notNull(),
485
- lockUntil: timestamp3("lock_until", { withTimezone: true, mode: "date" }),
486
- tokenVersion: integer3("token_version").default(0).notNull(),
487
- passwordResetTokenHash: text3("password_reset_token_hash"),
488
- passwordResetExpiresAt: timestamp3("password_reset_expires_at", {
489
- withTimezone: true,
490
- mode: "date"
491
- }),
492
- passwordResetPurpose: npPasswordResetPurposeEnum("password_reset_purpose"),
493
- createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
494
- updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
495
- });
496
- var npSiteMemberships = pgTable3(
497
- "np_site_memberships",
356
+ var npMemberRoleScopeEnum = pgEnum3("np_member_role_scope", [
357
+ "site",
358
+ "category",
359
+ "collection",
360
+ "thread"
361
+ ]);
362
+ var npMembers = pgTable3(
363
+ "np_members",
498
364
  {
499
- siteId: text3("site_id").notNull(),
500
- userId: uuid3("user_id").notNull().references(() => npUsers.id, { onDelete: "cascade" }),
501
- role: npUserRoleEnum("role").notNull(),
365
+ id: uuid3("id").defaultRandom().primaryKey(),
366
+ handle: text3("handle").notNull().unique(),
367
+ email: text3("email").notNull().unique(),
368
+ emailVerified: boolean2("email_verified").default(false).notNull(),
369
+ /** Argon2 hash. Nullable so SSO-only members can exist without a password. */
370
+ password: text3("password"),
371
+ displayName: text3("display_name").notNull(),
372
+ avatar: uuid3("avatar").references(() => npMedia.id),
373
+ bio: text3("bio"),
374
+ status: npMemberStatusEnum("status").default("pending").notNull(),
375
+ reputation: integer3("reputation").default(0).notNull(),
376
+ loginAttempts: integer3("login_attempts").default(0).notNull(),
377
+ lockUntil: timestamp3("lock_until", { withTimezone: true, mode: "date" }),
378
+ /** Bumped to invalidate every issued JWT (logout-everywhere, password reset). */
379
+ tokenVersion: integer3("token_version").default(0).notNull(),
380
+ passwordResetTokenHash: text3("password_reset_token_hash"),
381
+ passwordResetExpiresAt: timestamp3("password_reset_expires_at", {
382
+ withTimezone: true,
383
+ mode: "date"
384
+ }),
385
+ emailVerifyTokenHash: text3("email_verify_token_hash"),
386
+ emailVerifyExpiresAt: timestamp3("email_verify_expires_at", {
387
+ withTimezone: true,
388
+ mode: "date"
389
+ }),
390
+ /** Plugin-extensible bag — preferences, custom profile fields, etc. */
391
+ meta: jsonb3("meta").$type().default({}).notNull(),
392
+ /**
393
+ * Phase 16.3 — per-member notification preferences. Shape:
394
+ * { disabled?: string[] } — kinds the member opted out of
395
+ * { digest?: "off"|"daily"|"weekly" } — email digest cadence (16.4)
396
+ * Empty default = every kind enabled, no email digest.
397
+ */
398
+ notificationPrefs: jsonb3("notification_prefs").$type().default({}).notNull(),
502
399
  createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
503
400
  updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
504
401
  },
505
- (table) => [primaryKey2({ columns: [table.siteId, table.userId] })]
402
+ (table) => [index3("np_members_status_idx").on(table.status)]
506
403
  );
507
- var npUserOAuthIdentities = pgTable3(
508
- "np_user_oauth_identities",
404
+ var npMemberSessions = pgTable3("np_member_sessions", {
405
+ id: uuid3("id").defaultRandom().primaryKey(),
406
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
407
+ tokenHash: text3("token_hash").notNull(),
408
+ userAgent: text3("user_agent"),
409
+ ip: text3("ip"),
410
+ expiresAt: timestamp3("expires_at", { withTimezone: true, mode: "date" }).notNull(),
411
+ createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
412
+ });
413
+ var npMemberIdentities = pgTable3(
414
+ "np_member_identities",
509
415
  {
510
416
  id: uuid3("id").defaultRandom().primaryKey(),
511
- userId: uuid3("user_id").notNull().references(() => npUsers.id, { onDelete: "cascade" }),
417
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
512
418
  provider: text3("provider").notNull(),
513
- providerUserId: text3("provider_user_id").notNull(),
419
+ subject: text3("subject").notNull(),
420
+ email: text3("email"),
514
421
  /** Free-form per-provider metadata (avatar URL, scopes granted, etc.). */
515
422
  metadata: jsonb3("metadata").$type().default({}).notNull(),
516
423
  createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
517
424
  updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
518
425
  },
519
- (table) => ({
520
- providerSubjectUnique: unique2("np_user_oauth_identities_provider_subject_unique").on(
521
- table.provider,
522
- table.providerUserId
523
- ),
524
- userProviderUnique: unique2("np_user_oauth_identities_user_provider_unique").on(
525
- table.userId,
526
- table.provider
527
- ),
528
- userIdx: index3("np_user_oauth_identities_user_idx").on(table.userId)
529
- })
426
+ (table) => [
427
+ unique2("np_member_identities_provider_subject_uq").on(table.provider, table.subject),
428
+ unique2("np_member_identities_member_provider_uq").on(table.memberId, table.provider),
429
+ index3("np_member_identities_member_idx").on(table.memberId)
430
+ ]
530
431
  );
531
- var npSessions = pgTable3("np_sessions", {
532
- id: uuid3("id").defaultRandom().primaryKey(),
533
- userId: uuid3("user_id").notNull().references(() => npUsers.id, { onDelete: "cascade" }),
534
- tokenHash: text3("token_hash").notNull(),
535
- userAgent: text3("user_agent"),
536
- ip: text3("ip"),
537
- expiresAt: timestamp3("expires_at", { withTimezone: true, mode: "date" }).notNull(),
538
- createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
539
- });
540
- var npRevisions = pgTable3(
541
- "np_revisions",
432
+ var npMemberRoles = pgTable3(
433
+ "np_member_roles",
542
434
  {
543
435
  id: uuid3("id").defaultRandom().primaryKey(),
544
- collection: text3("collection").notNull(),
545
- documentId: text3("document_id").notNull(),
546
- version: integer3("version").notNull(),
547
- status: npRevisionStatusEnum("status").notNull(),
548
- snapshot: jsonb3("snapshot").$type().notNull(),
549
- changedFields: text3("changed_fields").array().notNull(),
550
- authorId: uuid3("author_id").references(() => npUsers.id),
551
- createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
436
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
437
+ role: text3("role").notNull(),
438
+ scopeType: npMemberRoleScopeEnum("scope_type").notNull(),
439
+ /** Nullable for `scope_type='site'`. Otherwise an opaque string id. */
440
+ scopeId: text3("scope_id"),
441
+ /**
442
+ * Phase 18 — the tenant the grant applies on. For
443
+ * `scope_type='site'` this column IS the site identifier
444
+ * (`scope_id` stays null because site is the root scope).
445
+ * For category / collection / thread grants, `site_id` says
446
+ * which tenant's category/collection/thread this row
447
+ * targets — the same slug exists on every site.
448
+ */
449
+ siteId: text3("site_id").default("default").notNull(),
450
+ grantedBy: uuid3("granted_by").references(() => npUsers.id),
451
+ grantedAt: timestamp3("granted_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
452
+ expiresAt: timestamp3("expires_at", { withTimezone: true, mode: "date" })
552
453
  },
553
- (table) => ({
554
- documentVersionUnique: unique2("np_revisions_document_id_version_unique").on(
555
- table.documentId,
556
- table.version
557
- ),
558
- collectionIdx: index3("np_revisions_collection_idx").on(table.collection),
559
- documentIdIdx: index3("np_revisions_document_id_idx").on(table.documentId)
560
- })
454
+ (table) => [
455
+ // Two indexes mirror the two access patterns: "what can this member
456
+ // do?" (memberId scan) and "who mods this scope?" (scope scan).
457
+ index3("np_member_roles_member_idx").on(table.memberId),
458
+ index3("np_member_roles_scope_idx").on(table.scopeType, table.scopeId),
459
+ index3("np_member_roles_site_idx").on(table.siteId, table.memberId),
460
+ // `scope_id` is null for site-wide grants. NULLS NOT
461
+ // DISTINCT makes two null `scope_id`s collide so the
462
+ // unique constraint enforces "one grant per (member, role,
463
+ // scope, site)." `site_id` widens the key so the same
464
+ // member can hold the same role on different tenants.
465
+ unique2("np_member_roles_grant_uq").on(table.memberId, table.role, table.scopeType, table.scopeId, table.siteId).nullsNotDistinct()
466
+ ]
561
467
  );
562
- var npSettings = pgTable3(
563
- "np_settings",
468
+ var npComments = pgTable3(
469
+ "np_comments",
564
470
  {
471
+ id: uuid3("id").defaultRandom().primaryKey(),
472
+ targetType: text3("target_type").notNull(),
473
+ targetId: uuid3("target_id").notNull(),
474
+ parentId: uuid3("parent_id").references(() => npComments.id, {
475
+ onDelete: "cascade"
476
+ }),
477
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
478
+ bodyMd: text3("body_md").notNull(),
479
+ bodyHtml: text3("body_html").notNull(),
480
+ status: npCommentStatusEnum("status").default("visible").notNull(),
481
+ hiddenByUserId: uuid3("hidden_by_user_id").references(() => npUsers.id),
482
+ hiddenByMemberId: uuid3("hidden_by_member_id").references(() => npMembers.id),
483
+ hiddenReason: text3("hidden_reason"),
484
+ editedAt: timestamp3("edited_at", { withTimezone: true, mode: "date" }),
485
+ /**
486
+ * Phase 18 — site this comment belongs to. Filled at insert
487
+ * time from the target document's site (canonical) so a
488
+ * forged request resolver can't smuggle a comment into the
489
+ * wrong site. Defaults to `'default'` for legacy single-
490
+ * tenant rows so the migration backfill is a no-op.
491
+ */
565
492
  siteId: text3("site_id").default("default").notNull(),
566
- key: text3("key").notNull(),
567
- value: jsonb3("value").$type().notNull(),
568
- updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
569
- updatedBy: uuid3("updated_by").references(() => npUsers.id)
493
+ createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
570
494
  },
571
- (table) => [primaryKey2({ columns: [table.siteId, table.key] })]
495
+ (table) => [
496
+ index3("np_comments_target_idx").on(table.targetType, table.targetId, table.createdAt),
497
+ index3("np_comments_member_idx").on(table.memberId, table.createdAt),
498
+ index3("np_comments_site_idx").on(table.siteId, table.createdAt)
499
+ ]
572
500
  );
573
- var npSlugHistory = pgTable3(
574
- "np_slug_history",
501
+ var npReactions = pgTable3(
502
+ "np_reactions",
575
503
  {
576
504
  id: uuid3("id").defaultRandom().primaryKey(),
505
+ targetType: text3("target_type").notNull(),
506
+ targetId: uuid3("target_id").notNull(),
507
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
508
+ kind: text3("kind").notNull(),
509
+ /** Phase 18 — site this reaction belongs to (derived from target). */
577
510
  siteId: text3("site_id").default("default").notNull(),
578
- collection: text3("collection").notNull(),
579
- documentId: text3("document_id").notNull(),
580
- oldSlug: text3("old_slug").notNull(),
581
- newSlug: text3("new_slug").notNull(),
582
511
  createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
583
512
  },
584
513
  (table) => [
585
- index3("np_slug_history_lookup_idx").on(table.siteId, table.collection, table.oldSlug),
586
- index3("np_slug_history_doc_idx").on(table.siteId, table.collection, table.documentId)
514
+ index3("np_reactions_target_idx").on(table.targetType, table.targetId),
515
+ index3("np_reactions_site_idx").on(table.siteId),
516
+ unique2("np_reactions_unique").on(table.targetType, table.targetId, table.memberId, table.kind)
587
517
  ]
588
518
  );
589
- var npNavigation = pgTable3(
590
- "np_navigation",
519
+ var npFollows = pgTable3(
520
+ "np_follows",
591
521
  {
592
522
  id: uuid3("id").defaultRandom().primaryKey(),
523
+ followerId: uuid3("follower_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
524
+ targetType: text3("target_type").notNull(),
525
+ targetId: text3("target_id").notNull(),
526
+ /**
527
+ * Phase 18 — site the follow happened on. The same global
528
+ * member can follow on multiple sites and each row scopes
529
+ * to where the click happened (so site-scoped notifications
530
+ * + activity feeds don't leak cross-tenant). The unique
531
+ * key is widened to include site_id so the same follower
532
+ * can have parallel follow rows under different tenants.
533
+ */
593
534
  siteId: text3("site_id").default("default").notNull(),
594
- location: text3("location").notNull(),
595
- items: jsonb3("items").$type().notNull(),
596
- updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
597
- updatedBy: uuid3("updated_by").references(() => npUsers.id)
535
+ createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
598
536
  },
599
- (table) => [unique2("np_navigation_site_location_idx").on(table.siteId, table.location)]
537
+ (table) => [
538
+ index3("np_follows_target_idx").on(table.targetType, table.targetId),
539
+ index3("np_follows_site_idx").on(table.siteId),
540
+ unique2("np_follows_unique").on(
541
+ table.followerId,
542
+ table.targetType,
543
+ table.targetId,
544
+ table.siteId
545
+ )
546
+ ]
600
547
  );
601
- var npStringOverrides = pgTable3(
602
- "np_string_overrides",
548
+ var npMemberMutes = pgTable3(
549
+ "np_member_mutes",
603
550
  {
551
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
552
+ targetId: uuid3("target_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
553
+ /**
554
+ * Phase 18 — site the mute applies to. A muter can choose
555
+ * to silence someone on one tenant without affecting their
556
+ * other tenants. PK is widened to include site_id so the
557
+ * same `(member, target)` pair can hold parallel rows per
558
+ * site.
559
+ */
604
560
  siteId: text3("site_id").default("default").notNull(),
605
- locale: text3("locale").notNull(),
606
- key: text3("key").notNull(),
607
- value: text3("value"),
608
- updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
609
- updatedBy: uuid3("updated_by").references(() => npUsers.id)
561
+ createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
610
562
  },
611
- (table) => [primaryKey2({ columns: [table.siteId, table.locale, table.key] })]
563
+ (table) => [
564
+ primaryKey2({ columns: [table.memberId, table.targetId, table.siteId] }),
565
+ index3("np_member_mutes_target_idx").on(table.targetId)
566
+ ]
612
567
  );
613
- var npSites = pgTable3(
614
- "np_sites",
568
+ var npNotifications = pgTable3(
569
+ "np_notifications",
615
570
  {
616
- id: text3("id").primaryKey(),
617
- name: text3("name").notNull(),
618
- hostname: text3("hostname"),
619
- description: text3("description"),
620
- settings: jsonb3("settings").$type().default({}).notNull(),
621
- isDefault: boolean2("is_default").default(false).notNull(),
622
- createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
623
- updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
571
+ id: uuid3("id").defaultRandom().primaryKey(),
572
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
573
+ kind: text3("kind").notNull(),
574
+ payload: jsonb3("payload").$type().default({}).notNull(),
575
+ readAt: timestamp3("read_at", { withTimezone: true, mode: "date" }),
576
+ /**
577
+ * Phase 18 site this notification belongs to. A member
578
+ * who's active on multiple tenants gets one inbox per site
579
+ * (the inbox API filters by current site) so cross-tenant
580
+ * activity doesn't bleed into the wrong site's UI.
581
+ */
582
+ siteId: text3("site_id").default("default").notNull(),
583
+ createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
624
584
  },
625
- (table) => [unique2("np_sites_hostname_idx").on(table.hostname)]
585
+ (table) => [
586
+ index3("np_notifications_inbox_idx").on(table.memberId, table.readAt, table.createdAt),
587
+ index3("np_notifications_site_inbox_idx").on(table.siteId, table.memberId, table.readAt)
588
+ ]
626
589
  );
627
- var npPlugins = pgTable3("np_plugins", {
628
- id: text3("id").primaryKey(),
629
- enabled: boolean2("enabled").default(true).notNull(),
630
- installedAt: timestamp3("installed_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
631
- updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
632
- });
633
- var NP_GLOBAL_PLUGIN_SITE_ID = "_global_";
634
- var npPluginStorage = pgTable3(
635
- "np_plugin_storage",
590
+ var npReports = pgTable3(
591
+ "np_reports",
636
592
  {
637
- pluginId: text3("plugin_id").notNull(),
638
- siteId: text3("site_id").default(NP_GLOBAL_PLUGIN_SITE_ID).notNull(),
639
- key: text3("key").notNull(),
640
- value: jsonb3("value").$type().notNull(),
641
- expiresAt: timestamp3("expires_at", { withTimezone: true, mode: "date" }),
642
- updatedAt: timestamp3("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
593
+ id: uuid3("id").defaultRandom().primaryKey(),
594
+ reporterId: uuid3("reporter_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
595
+ targetType: text3("target_type").notNull(),
596
+ targetId: text3("target_id").notNull(),
597
+ reason: text3("reason").notNull(),
598
+ resolvedAt: timestamp3("resolved_at", { withTimezone: true, mode: "date" }),
599
+ resolvedByUserId: uuid3("resolved_by_user_id").references(() => npUsers.id),
600
+ resolvedByMemberId: uuid3("resolved_by_member_id").references(() => npMembers.id),
601
+ resolution: text3("resolution"),
602
+ /**
603
+ * Phase 18 — site this report belongs to. The mod queue
604
+ * is per-site so a category-mod on tenant A doesn't see
605
+ * tenant B's reports.
606
+ */
607
+ siteId: text3("site_id").default("default").notNull(),
608
+ createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
643
609
  },
644
- (table) => ({
645
- pk: primaryKey2({ columns: [table.pluginId, table.siteId, table.key] }),
646
- pluginIdx: index3("np_plugin_storage_plugin_id_idx").on(table.pluginId),
647
- siteIdx: index3("np_plugin_storage_site_idx").on(table.siteId)
648
- })
610
+ (table) => [
611
+ index3("np_reports_queue_idx").on(table.resolvedAt, table.createdAt),
612
+ index3("np_reports_target_idx").on(table.targetType, table.targetId),
613
+ index3("np_reports_site_queue_idx").on(table.siteId, table.resolvedAt)
614
+ ]
649
615
  );
650
- var npWorkerHeartbeats = pgTable3("np_worker_heartbeats", {
651
- id: text3("id").primaryKey(),
652
- status: text3("status").default("running").notNull(),
653
- startedAt: timestamp3("started_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
654
- lastSeenAt: timestamp3("last_seen_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
655
- /** Free-form metadata (worker version, hostname, env). */
656
- meta: jsonb3("meta").$type().default({}).notNull()
657
- });
658
- var npJobLogs = pgTable3(
659
- "np_job_logs",
616
+ var npAuditEvents = pgTable3(
617
+ "np_audit_events",
660
618
  {
661
619
  id: uuid3("id").defaultRandom().primaryKey(),
662
- jobId: text3("job_id").notNull(),
663
- level: text3("level").notNull(),
664
- message: text3("message").notNull(),
665
- context: jsonb3("context").$type(),
620
+ actorKind: text3("actor_kind").notNull(),
621
+ actorUserId: uuid3("actor_user_id").references(() => npUsers.id),
622
+ actorMemberId: uuid3("actor_member_id").references(() => npMembers.id),
623
+ action: text3("action").notNull(),
624
+ targetType: text3("target_type"),
625
+ targetId: text3("target_id"),
626
+ payload: jsonb3("payload").$type().default({}).notNull(),
627
+ /**
628
+ * Phase 17 — site-scoped audit. Filled by `recordAuditEvent`
629
+ * from the current request's site (the multi-site resolver).
630
+ * Nullable for events that don't belong to a single site
631
+ * (super-admin actions, background jobs, scripts).
632
+ */
633
+ siteId: text3("site_id"),
634
+ createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
635
+ },
636
+ (table) => [
637
+ index3("np_audit_target_idx").on(table.targetType, table.targetId, table.createdAt),
638
+ index3("np_audit_actor_user_idx").on(table.actorUserId, table.createdAt),
639
+ index3("np_audit_actor_member_idx").on(table.actorMemberId, table.createdAt),
640
+ index3("np_audit_site_idx").on(table.siteId, table.createdAt)
641
+ ]
642
+ );
643
+ var npBans = pgTable3(
644
+ "np_bans",
645
+ {
646
+ id: uuid3("id").defaultRandom().primaryKey(),
647
+ memberId: uuid3("member_id").notNull().references(() => npMembers.id, { onDelete: "cascade" }),
648
+ scopeType: npBanScopeEnum("scope_type").notNull(),
649
+ scopeId: text3("scope_id"),
650
+ kind: npBanKindEnum("kind").notNull(),
651
+ expiresAt: timestamp3("expires_at", { withTimezone: true, mode: "date" }),
652
+ reason: text3("reason"),
653
+ byUserId: uuid3("by_user_id").references(() => npUsers.id),
654
+ byMemberId: uuid3("by_member_id").references(() => npMembers.id),
655
+ /**
656
+ * Phase 18 — the tenant this ban applies to. Pre-Phase 18
657
+ * `scope_type='site'` rows had `scope_id=null` because
658
+ * "site" was the singular root scope; with multi-tenancy
659
+ * the column tells `assertNotBanned` WHICH site the ban
660
+ * blocks writes on. Category / collection scopes resolve
661
+ * per-site too — the same `posts` collection slug exists
662
+ * on every tenant.
663
+ */
664
+ siteId: text3("site_id").default("default").notNull(),
666
665
  createdAt: timestamp3("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull()
667
666
  },
668
667
  (table) => [
669
- index3("np_job_logs_job_idx").on(table.jobId, table.createdAt),
670
- index3("np_job_logs_created_idx").on(table.createdAt)
668
+ index3("np_bans_member_scope_idx").on(table.memberId, table.scopeType, table.scopeId),
669
+ index3("np_bans_active_idx").on(table.memberId, table.expiresAt),
670
+ index3("np_bans_site_idx").on(table.siteId, table.memberId)
671
671
  ]
672
672
  );
673
673
 
674
674
  export {
675
+ npMemberStatusEnum,
676
+ npBanScopeEnum,
677
+ npBanKindEnum,
678
+ npCommentStatusEnum,
679
+ npMemberRoleScopeEnum,
680
+ npMembers,
681
+ npMemberSessions,
682
+ npMemberIdentities,
683
+ npMemberRoles,
684
+ npComments,
685
+ npReactions,
686
+ npFollows,
687
+ npMemberMutes,
688
+ npNotifications,
689
+ npReports,
690
+ npAuditEvents,
691
+ npBans,
692
+ npMediaStatusEnum,
693
+ npMediaFolders,
694
+ npMedia,
695
+ npMediaRefs,
675
696
  npUserRoleEnum,
676
697
  npRevisionStatusEnum,
677
698
  npPasswordResetPurposeEnum,
@@ -689,27 +710,6 @@ export {
689
710
  NP_GLOBAL_PLUGIN_SITE_ID,
690
711
  npPluginStorage,
691
712
  npWorkerHeartbeats,
692
- npJobLogs,
693
- npMediaStatusEnum,
694
- npMediaFolders,
695
- npMedia,
696
- npMediaRefs,
697
- npMemberStatusEnum,
698
- npBanScopeEnum,
699
- npBanKindEnum,
700
- npCommentStatusEnum,
701
- npMemberRoleScopeEnum,
702
- npMembers,
703
- npMemberSessions,
704
- npMemberIdentities,
705
- npMemberRoles,
706
- npComments,
707
- npReactions,
708
- npFollows,
709
- npMemberMutes,
710
- npNotifications,
711
- npReports,
712
- npAuditEvents,
713
- npBans
713
+ npJobLogs
714
714
  };
715
- //# sourceMappingURL=chunk-M43PGOQY.js.map
715
+ //# sourceMappingURL=chunk-X7K5F2UI.js.map