-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathschema.sql
More file actions
429 lines (383 loc) · 13.9 KB
/
schema.sql
File metadata and controls
429 lines (383 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
-- ============================================================
-- Commons by Codezela — Full Database Schema
-- Run this against your Supabase PostgreSQL database
-- ============================================================
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- Better Auth tables (user, session, account, verification)
-- ============================================================
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
"emailVerified" BOOLEAN NOT NULL DEFAULT FALSE,
image TEXT,
role TEXT NOT NULL DEFAULT 'reader',
banned BOOLEAN DEFAULT FALSE,
"banReason" TEXT,
"banExpires" BIGINT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE "user" ALTER COLUMN role SET DEFAULT 'reader';
UPDATE "user" SET role = 'reader' WHERE role = 'user';
UPDATE "user" SET role = 'reader' WHERE role IS NULL OR role NOT IN ('admin', 'moderator', 'reader');
ALTER TABLE "user" DROP CONSTRAINT IF EXISTS user_role_check;
ALTER TABLE "user"
ADD CONSTRAINT user_role_check CHECK (role IN ('admin', 'moderator', 'reader'));
CREATE OR REPLACE FUNCTION normalize_user_role()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.role IS NULL OR NEW.role = 'user' THEN
NEW.role := 'reader';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_normalize_user_role ON "user";
CREATE TRIGGER trg_normalize_user_role
BEFORE INSERT OR UPDATE OF role ON "user"
FOR EACH ROW
EXECUTE FUNCTION normalize_user_role();
CREATE TABLE IF NOT EXISTS "session" (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
"expiresAt" TIMESTAMPTZ NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS "account" (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"accessTokenExpiresAt" TIMESTAMPTZ,
"refreshTokenExpiresAt" TIMESTAMPTZ,
scope TEXT,
"idToken" TEXT,
password TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS "verification" (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- Application tables
-- ============================================================
-- Categories
CREATE TABLE IF NOT EXISTS category (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tags
CREATE TABLE IF NOT EXISTS tag (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'approved' CHECK (status IN ('approved','pending','rejected')),
created_by TEXT REFERENCES "user"(id) ON DELETE SET NULL,
approved_at TIMESTAMPTZ,
moderation_note TEXT,
reviewed_by TEXT REFERENCES "user"(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE tag ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'approved' CHECK (status IN ('approved','pending','rejected'));
ALTER TABLE tag ADD COLUMN IF NOT EXISTS created_by TEXT REFERENCES "user"(id) ON DELETE SET NULL;
ALTER TABLE tag ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
ALTER TABLE tag ADD COLUMN IF NOT EXISTS moderation_note TEXT;
ALTER TABLE tag ADD COLUMN IF NOT EXISTS reviewed_by TEXT REFERENCES "user"(id) ON DELETE SET NULL;
ALTER TABLE tag ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;
UPDATE tag SET status = 'approved' WHERE status IS NULL;
-- Articles
CREATE TABLE IF NOT EXISTS article (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
seo_title TEXT,
seo_description TEXT,
seo_image TEXT,
canonical_url TEXT,
robots_noindex BOOLEAN NOT NULL DEFAULT FALSE,
content JSONB, -- TipTap JSON (ProseMirror AST)
content_html TEXT, -- Pre-rendered HTML for public display
content_text TEXT, -- Plain text for full-text search
cover_image TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','pending','published','rejected','archived')),
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
featured_order INTEGER,
moderation_note TEXT,
reviewed_by TEXT REFERENCES "user"(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
author_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
category_id TEXT REFERENCES category(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Full-text search vector
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(content_text, '')), 'B')
) STORED
);
ALTER TABLE article ADD COLUMN IF NOT EXISTS seo_title TEXT;
ALTER TABLE article ADD COLUMN IF NOT EXISTS seo_description TEXT;
ALTER TABLE article ADD COLUMN IF NOT EXISTS seo_image TEXT;
ALTER TABLE article ADD COLUMN IF NOT EXISTS canonical_url TEXT;
ALTER TABLE article ADD COLUMN IF NOT EXISTS robots_noindex BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE article ADD COLUMN IF NOT EXISTS moderation_note TEXT;
ALTER TABLE article ADD COLUMN IF NOT EXISTS reviewed_by TEXT REFERENCES "user"(id) ON DELETE SET NULL;
ALTER TABLE article ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;
ALTER TABLE article DROP COLUMN IF EXISTS excerpt;
-- GIN index for full-text search
CREATE INDEX IF NOT EXISTS idx_article_search ON article USING GIN (search_vector);
CREATE INDEX IF NOT EXISTS idx_article_status ON article (status);
CREATE INDEX IF NOT EXISTS idx_article_author ON article (author_id);
CREATE INDEX IF NOT EXISTS idx_article_category ON article (category_id);
CREATE INDEX IF NOT EXISTS idx_article_featured ON article (is_featured, featured_order) WHERE is_featured = TRUE;
CREATE INDEX IF NOT EXISTS idx_article_published_at ON article (published_at DESC) WHERE status = 'published';
CREATE INDEX IF NOT EXISTS idx_article_slug ON article (slug);
CREATE INDEX IF NOT EXISTS idx_article_pending_updated ON article (updated_at DESC) WHERE status = 'pending';
-- Article ↔ Tag many-to-many
CREATE TABLE IF NOT EXISTS article_tag (
article_id TEXT NOT NULL REFERENCES article(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
PRIMARY KEY (article_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_article_tag_tag ON article_tag (tag_id);
CREATE INDEX IF NOT EXISTS idx_tag_status_created ON tag (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_tag_created_by ON tag (created_by);
-- Article Reactions (one reaction per user per article)
CREATE TABLE IF NOT EXISTS article_reaction (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
article_id TEXT NOT NULL REFERENCES article(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
reaction_type TEXT NOT NULL CHECK (reaction_type IN ('like', 'insightful', 'inspiring', 'curious')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (article_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_article_reaction_article ON article_reaction (article_id);
CREATE INDEX IF NOT EXISTS idx_article_reaction_user ON article_reaction (user_id);
-- Admin/Moderator audit trail
CREATE TABLE IF NOT EXISTS admin_audit_log (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
actor_id TEXT REFERENCES "user"(id) ON DELETE SET NULL,
actor_role TEXT NOT NULL CHECK (actor_role IN ('admin', 'moderator', 'reader')),
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
target_label TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at ON admin_audit_log (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_actor_id ON admin_audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action_target ON admin_audit_log (action, target_type);
CREATE OR REPLACE FUNCTION audit_actor_role_for_user(actor_user_id TEXT)
RETURNS TEXT AS $$
DECLARE
resolved_role TEXT;
BEGIN
IF actor_user_id IS NULL THEN
RETURN 'reader';
END IF;
SELECT role INTO resolved_role
FROM "user"
WHERE id = actor_user_id
LIMIT 1;
IF resolved_role IN ('admin', 'moderator', 'reader') THEN
RETURN resolved_role;
END IF;
RETURN 'reader';
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION audit_log_auth_signup()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO admin_audit_log (
actor_id,
actor_role,
action,
target_type,
target_id,
target_label,
metadata
) VALUES (
NEW.id,
COALESCE(NEW.role, 'reader'),
'auth.signup',
'user',
NEW.id,
NEW.email,
jsonb_build_object('emailVerified', NEW."emailVerified")
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_audit_auth_signup ON "user";
CREATE TRIGGER trg_audit_auth_signup
AFTER INSERT ON "user"
FOR EACH ROW
EXECUTE FUNCTION audit_log_auth_signup();
CREATE OR REPLACE FUNCTION audit_log_session_created()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO admin_audit_log (
actor_id,
actor_role,
action,
target_type,
target_id,
target_label,
metadata
) VALUES (
NEW."userId",
audit_actor_role_for_user(NEW."userId"),
'auth.session.created',
'session',
NEW.id,
NEW."userAgent",
jsonb_build_object(
'ipAddress', NEW."ipAddress",
'expiresAt', NEW."expiresAt"
)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_audit_session_created ON "session";
CREATE TRIGGER trg_audit_session_created
AFTER INSERT ON "session"
FOR EACH ROW
EXECUTE FUNCTION audit_log_session_created();
CREATE OR REPLACE FUNCTION audit_log_session_revoked()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO admin_audit_log (
actor_id,
actor_role,
action,
target_type,
target_id,
target_label,
metadata
) VALUES (
OLD."userId",
audit_actor_role_for_user(OLD."userId"),
'auth.session.revoked',
'session',
OLD.id,
OLD."userAgent",
jsonb_build_object(
'ipAddress', OLD."ipAddress",
'expiresAt', OLD."expiresAt"
)
);
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_audit_session_revoked ON "session";
CREATE TRIGGER trg_audit_session_revoked
AFTER DELETE ON "session"
FOR EACH ROW
EXECUTE FUNCTION audit_log_session_revoked();
CREATE OR REPLACE FUNCTION audit_log_password_updated()
RETURNS TRIGGER AS $$
BEGIN
IF NEW."providerId" = 'credential' AND OLD.password IS DISTINCT FROM NEW.password THEN
INSERT INTO admin_audit_log (
actor_id,
actor_role,
action,
target_type,
target_id,
target_label,
metadata
) VALUES (
NEW."userId",
audit_actor_role_for_user(NEW."userId"),
'auth.password.updated',
'account',
NEW.id,
NEW."providerId",
jsonb_build_object('providerId', NEW."providerId")
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_audit_password_updated ON account;
CREATE TRIGGER trg_audit_password_updated
AFTER UPDATE OF password ON account
FOR EACH ROW
EXECUTE FUNCTION audit_log_password_updated();
CREATE OR REPLACE FUNCTION audit_log_verification_requested()
RETURNS TRIGGER AS $$
DECLARE
identified_user_id TEXT;
BEGIN
SELECT id INTO identified_user_id
FROM "user"
WHERE email = NEW.identifier
LIMIT 1;
INSERT INTO admin_audit_log (
actor_id,
actor_role,
action,
target_type,
target_id,
target_label,
metadata
) VALUES (
identified_user_id,
audit_actor_role_for_user(identified_user_id),
'auth.verification.requested',
'verification',
NEW.id,
NEW.identifier,
jsonb_build_object('expiresAt', NEW."expiresAt")
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_audit_verification_requested ON verification;
CREATE TRIGGER trg_audit_verification_requested
AFTER INSERT ON verification
FOR EACH ROW
EXECUTE FUNCTION audit_log_verification_requested();
-- Rate limit counters
CREATE TABLE IF NOT EXISTS rate_limit_bucket (
rate_key TEXT NOT NULL,
bucket_start TIMESTAMPTZ NOT NULL,
count INTEGER NOT NULL DEFAULT 0 CHECK (count >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (rate_key, bucket_start)
);
CREATE INDEX IF NOT EXISTS idx_rate_limit_bucket_updated_at ON rate_limit_bucket (updated_at);
-- ============================================================
-- Seed default categories
-- ============================================================
INSERT INTO category (id, name, slug, description) VALUES
(gen_random_uuid()::text, 'Computer Science', 'computer-science', 'Research in CS, algorithms, and software engineering'),
(gen_random_uuid()::text, 'Mathematics', 'mathematics', 'Pure and applied mathematics research'),
(gen_random_uuid()::text, 'Physics', 'physics', 'Theoretical and experimental physics'),
(gen_random_uuid()::text, 'Biology', 'biology', 'Life sciences and biological research'),
(gen_random_uuid()::text, 'Engineering', 'engineering', 'Engineering disciplines and applied sciences'),
(gen_random_uuid()::text, 'Social Sciences', 'social-sciences', 'Psychology, sociology, economics, and related fields')
ON CONFLICT DO NOTHING;