feat: add share schema and Discord account reconciliation

This commit is contained in:
2026-08-16 00:48:24 +07:00 Unverified
parent 59a9cc1c19
commit b8e6389ac3
11 changed files with 2517 additions and 13 deletions
+1
View File
@@ -1,3 +1,4 @@
export * from "./schema/account";
export * from "./schema/form";
export * from "./schema/video";
export * from "./schema/share";
+7 -2
View File
@@ -4,12 +4,15 @@ import {
timestamp,
integer,
primaryKey,
uniqueIndex,
} from "drizzle-orm/pg-core";
import type { AdapterAccount } from "next-auth/adapters";
export const accountSchema = pgSchema("account");
export const users = accountSchema.table("user", {
export const users = accountSchema.table(
"user",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
@@ -18,7 +21,9 @@ export const users = accountSchema.table("user", {
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
discordId: text("discord_id"),
});
},
(user) => [uniqueIndex("user_discord_id_unique").on(user.discordId)]
);
export const accounts = accountSchema.table(
"account",
+85
View File
@@ -0,0 +1,85 @@
import { randomBytes } from "node:crypto";
import {
index,
pgSchema,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7";
import { users } from "./account";
import { cdn } from "./form";
export const shareSchema = pgSchema("share");
export type ShareSource = "web" | "bot";
const SHARE_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
export function createShareId() {
let id = "";
while (id.length < 8) {
for (const value of randomBytes(8)) {
if (value >= 252) continue;
id += SHARE_ID_ALPHABET[value % SHARE_ID_ALPHABET.length];
if (id.length === 8) break;
}
}
return id;
}
export const shareTexts = shareSchema.table(
"text",
{
id: text("id").primaryKey().$defaultFn(createShareId),
content: text("content").notNull(),
description: text("description"),
imageCdnId: text("image_cdn_id").references(() => cdn.id, {
onDelete: "set null",
}),
source: text("source").$type<ShareSource>().notNull(),
authorId: text("author_id").references(() => users.id, {
onDelete: "set null",
}),
authorDiscordId: text("author_discord_id").notNull(),
authorName: text("author_name").notNull(),
authorAvatarUrl: text("author_avatar_url"),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.defaultNow()
.notNull(),
},
(share) => [
index("share_text_author_created_idx").on(
share.authorDiscordId,
share.createdAt
),
]
);
export const shareComments = shareSchema.table(
"comment",
{
id: text("id")
.primaryKey()
.$defaultFn(() => uuidv7()),
shareId: text("share_id")
.notNull()
.references(() => shareTexts.id, { onDelete: "cascade" }),
body: text("body").notNull(),
authorId: text("author_id").references(() => users.id, {
onDelete: "set null",
}),
authorDiscordId: text("author_discord_id").notNull(),
authorName: text("author_name").notNull(),
authorAvatarUrl: text("author_avatar_url"),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.defaultNow()
.notNull(),
},
(comment) => [
index("share_comment_share_created_id_idx").on(
comment.shareId,
comment.createdAt,
comment.id
),
]
);
+1 -1
View File
@@ -7,5 +7,5 @@ export default defineConfig({
dbCredentials: {
url: process.env.DATABASE_URL!,
},
schemaFilter: ["public", "account", "form"],
schemaFilter: ["public", "account", "form", "share"],
});
+35
View File
@@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS "public"."video_upload" (
"id" text PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"description" text DEFAULT '' NOT NULL,
"video_path" text NOT NULL,
"thumbnail_path" text,
"original_filename" text NOT NULL,
"mime_type" text NOT NULL,
"size" bigint NOT NULL,
"scheduled_at" timestamptz,
"status" text DEFAULT 'draft' NOT NULL,
"youtube_settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"tiktok_settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_by_discord_id" text NOT NULL,
"created_at" timestamptz DEFAULT now() NOT NULL,
"updated_at" timestamptz DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "video_upload_status_scheduled_idx" ON "public"."video_upload" ("status", "scheduled_at");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "public"."video_upload_job" (
"id" text PRIMARY KEY NOT NULL,
"upload_id" text NOT NULL REFERENCES "public"."video_upload"("id") ON DELETE cascade,
"platform" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"external_id" text,
"external_url" text,
"error" text,
"attempts" integer DEFAULT 0 NOT NULL,
"started_at" timestamptz,
"completed_at" timestamptz,
"created_at" timestamptz DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "video_upload_job_upload_platform_idx" ON "public"."video_upload_job" ("upload_id", "platform");
+39
View File
@@ -0,0 +1,39 @@
CREATE SCHEMA "share";
--> statement-breakpoint
CREATE TABLE "share"."comment" (
"id" text PRIMARY KEY NOT NULL,
"share_id" text NOT NULL,
"body" text NOT NULL,
"author_id" text,
"author_discord_id" text NOT NULL,
"author_name" text NOT NULL,
"author_avatar_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "share"."text" (
"id" text PRIMARY KEY NOT NULL,
"content" text NOT NULL,
"description" text,
"image_cdn_id" text,
"source" text NOT NULL,
"author_id" text,
"author_discord_id" text NOT NULL,
"author_name" text NOT NULL,
"author_avatar_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "share"."comment" ADD CONSTRAINT "comment_share_id_text_id_fk" FOREIGN KEY ("share_id") REFERENCES "share"."text"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "share"."comment" ADD CONSTRAINT "comment_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "account"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "share"."text" ADD CONSTRAINT "text_image_cdn_id_cdn_id_fk" FOREIGN KEY ("image_cdn_id") REFERENCES "public"."cdn"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "share"."text" ADD CONSTRAINT "text_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "account"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "share_comment_share_created_id_idx" ON "share"."comment" USING btree ("share_id","created_at","id");--> statement-breakpoint
CREATE INDEX "share_text_author_created_idx" ON "share"."text" USING btree ("author_discord_id","created_at");--> statement-breakpoint
UPDATE "account"."user" AS "user"
SET "discord_id" = "account"."providerAccountId"
FROM "account"."account" AS "account"
WHERE "account"."provider" = 'discord'
AND "account"."userId" = "user"."id"
AND "user"."discord_id" IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "user_discord_id_unique" ON "account"."user" USING btree ("discord_id");
+941
View File
@@ -0,0 +1,941 @@
{
"id": "a15effd0-7081-48ba-a1c9-fa11fed9a810",
"prevId": "94182237-a4fa-4101-b263-2689df4a2fad",
"version": "7",
"dialect": "postgresql",
"tables": {
"account.account": {
"name": "account",
"schema": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"schemaTo": "account",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"name": "account_provider_providerAccountId_pk",
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"form.answer": {
"name": "answer",
"schema": "form",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"submission_id": {
"name": "submission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"question_id": {
"name": "question_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"image_cdn_id": {
"name": "image_cdn_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"answer_submission_id_submission_id_fk": {
"name": "answer_submission_id_submission_id_fk",
"tableFrom": "answer",
"tableTo": "submission",
"schemaTo": "form",
"columnsFrom": [
"submission_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"answer_question_id_question_id_fk": {
"name": "answer_question_id_question_id_fk",
"tableFrom": "answer",
"tableTo": "question",
"schemaTo": "form",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"answer_image_cdn_id_cdn_id_fk": {
"name": "answer_image_cdn_id_cdn_id_fk",
"tableFrom": "answer",
"tableTo": "cdn",
"columnsFrom": [
"image_cdn_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.cdn": {
"name": "cdn",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"data": {
"name": "data",
"type": "bytea",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"owner_discord_id": {
"name": "owner_discord_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"form.form": {
"name": "form",
"schema": "form",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"is_open": {
"name": "is_open",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"closes_at": {
"name": "closes_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"discord_webhook_url": {
"name": "discord_webhook_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discord_webhook_template": {
"name": "discord_webhook_template",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discord_webhook_update_template": {
"name": "discord_webhook_update_template",
"type": "text",
"primaryKey": false,
"notNull": false
},
"allowed_roles": {
"name": "allowed_roles",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"denied_roles": {
"name": "denied_roles",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"allow_multiple_submissions": {
"name": "allow_multiple_submissions",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"form.question": {
"name": "question",
"schema": "form",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"form_id": {
"name": "form_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'text'"
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_order": {
"name": "display_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"required": {
"name": "required",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"options": {
"name": "options",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"allow_other": {
"name": "allow_other",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image_answer_mode": {
"name": "image_answer_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'none'"
}
},
"indexes": {},
"foreignKeys": {
"question_form_id_form_id_fk": {
"name": "question_form_id_form_id_fk",
"tableFrom": "question",
"tableTo": "form",
"schemaTo": "form",
"columnsFrom": [
"form_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"account.session": {
"name": "session",
"schema": "account",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"schemaTo": "account",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"form.submission": {
"name": "submission",
"schema": "form",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"form_id": {
"name": "form_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_discord_id": {
"name": "user_discord_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"admin_note": {
"name": "admin_note",
"type": "text",
"primaryKey": false,
"notNull": false
},
"submitted_at": {
"name": "submitted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"edit_history": {
"name": "edit_history",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
}
},
"indexes": {
"submission_form_submitted_at_id_idx": {
"name": "submission_form_submitted_at_id_idx",
"columns": [
{
"expression": "form_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "submitted_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"submission_form_id_form_id_fk": {
"name": "submission_form_id_form_id_fk",
"tableFrom": "submission",
"tableTo": "form",
"schemaTo": "form",
"columnsFrom": [
"form_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"account.user": {
"name": "user",
"schema": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"account.verificationToken": {
"name": "verificationToken",
"schema": "account",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.video_upload_job": {
"name": "video_upload_job",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"upload_id": {
"name": "upload_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_url": {
"name": "external_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"video_upload_job_upload_platform_idx": {
"name": "video_upload_job_upload_platform_idx",
"columns": [
{
"expression": "upload_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "platform",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"video_upload_job_upload_id_video_upload_id_fk": {
"name": "video_upload_job_upload_id_video_upload_id_fk",
"tableFrom": "video_upload_job",
"tableTo": "video_upload",
"columnsFrom": [
"upload_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.video_upload": {
"name": "video_upload",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"original_filename": {
"name": "original_filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"scheduled_at": {
"name": "scheduled_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"youtube_settings": {
"name": "youtube_settings",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"tiktok_settings": {
"name": "tiktok_settings",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_by_discord_id": {
"name": "created_by_discord_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"video_upload_status_scheduled_idx": {
"name": "video_upload_status_scheduled_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "scheduled_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {
"account": "account",
"form": "form"
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -127,6 +127,13 @@
"when": 1785435766277,
"tag": "0017_friendly_video_upload",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1786816029107,
"tag": "0018_authenticated_share",
"breakpoints": true
}
]
}
+59 -1
View File
@@ -5,10 +5,33 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { accounts, sessions, users, verificationTokens } from "@/db/schema";
import type { SessionWithDiscord } from "@/lib/auth/auth";
import { ensureDiscordUser } from "@/lib/auth/discord-user";
import { addDiscordGuildMember } from "@/lib/discord/discord";
const discordIdCache = new Map<string, string>();
type DiscordOAuthProfile = {
id: string;
username: string;
global_name?: string | null;
discriminator?: string;
avatar?: string | null;
email?: string | null;
};
function discordAvatarUrl(profile: DiscordOAuthProfile) {
if (profile.avatar) {
const format = profile.avatar.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}?size=128`;
}
const discriminator = Number(profile.discriminator ?? 0);
const index = discriminator > 0
? discriminator % 5
: Number((BigInt(profile.id) >> BigInt(22)) % BigInt(6));
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
}
export const authOptions: NextAuthOptions = {
adapter: DrizzleAdapter(db, {
usersTable: users,
@@ -25,11 +48,46 @@ export const authOptions: NextAuthOptions = {
scope: "identify email guilds.join",
},
},
profile(profile: DiscordOAuthProfile) {
return {
id: profile.id,
name: profile.global_name?.trim() || profile.username,
email: profile.email,
image: discordAvatarUrl(profile),
};
},
}),
],
callbacks: {
async signIn({ account }) {
async signIn({ account, profile, user }) {
if (account?.provider === "discord" && account.providerAccountId && account.access_token) {
const discordProfile = profile as DiscordOAuthProfile | undefined;
const displayName =
discordProfile?.global_name?.trim() ||
discordProfile?.username ||
user.name ||
"Discord user";
const avatarUrl = discordProfile
? discordAvatarUrl(discordProfile)
: user.image ?? null;
const reconciled = await ensureDiscordUser({
discordId: account.providerAccountId,
displayName,
avatarUrl,
email: discordProfile?.email ?? user.email ?? null,
account: {
type: "oauth",
refresh_token: account.refresh_token,
access_token: account.access_token,
expires_at: account.expires_at,
token_type: account.token_type,
scope: account.scope,
id_token: account.id_token,
session_state: account.session_state,
},
});
discordIdCache.set(reconciled.id, account.providerAccountId);
await addDiscordGuildMember(account.providerAccountId, account.access_token);
}
+132
View File
@@ -0,0 +1,132 @@
import "server-only";
import type { AdapterAccount } from "next-auth/adapters";
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { accounts, users } from "@/db/schema";
export type DiscordIdentity = {
discordId: string;
displayName: string;
avatarUrl: string | null;
email?: string | null;
account?: Partial<AdapterAccount>;
};
function accountUpdate(account: Partial<AdapterAccount> | undefined) {
if (!account) return {};
return {
...(account.type !== undefined ? { type: account.type } : {}),
...(account.refresh_token !== undefined
? { refresh_token: account.refresh_token }
: {}),
...(account.access_token !== undefined
? { access_token: account.access_token }
: {}),
...(account.expires_at !== undefined
? { expires_at: account.expires_at }
: {}),
...(account.token_type !== undefined
? { token_type: account.token_type }
: {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
...(account.session_state !== undefined
? { session_state: account.session_state as string | null }
: {}),
};
}
export async function ensureDiscordUser(
identity: DiscordIdentity
): Promise<typeof users.$inferSelect> {
const { discordId, displayName, avatarUrl, email, account } = identity;
return db.transaction(async (tx) => {
const [linked] = await tx
.select({ userId: accounts.userId })
.from(accounts)
.where(
and(
eq(accounts.provider, "discord"),
eq(accounts.providerAccountId, discordId)
)
)
.limit(1);
let userId = linked?.userId;
if (!userId) {
const [user] = await tx
.insert(users)
.values({
discordId,
name: displayName,
image: avatarUrl,
...(email !== undefined ? { email } : {}),
})
.onConflictDoUpdate({
target: users.discordId,
set: {
name: displayName,
image: avatarUrl,
...(email !== undefined ? { email } : {}),
},
})
.returning({ id: users.id });
userId = user.id;
await tx
.insert(accounts)
.values({
userId,
provider: "discord",
providerAccountId: discordId,
type: account?.type ?? "oauth",
...accountUpdate(account),
})
.onConflictDoNothing({
target: [accounts.provider, accounts.providerAccountId],
});
const [winner] = await tx
.select({ userId: accounts.userId })
.from(accounts)
.where(
and(
eq(accounts.provider, "discord"),
eq(accounts.providerAccountId, discordId)
)
)
.limit(1);
if (!winner) throw new Error("Failed to link Discord account");
userId = winner.userId;
}
const [user] = await tx
.update(users)
.set({
discordId,
name: displayName,
image: avatarUrl,
...(email !== undefined ? { email } : {}),
})
.where(eq(users.id, userId))
.returning();
const nextAccount = accountUpdate(account);
if (Object.keys(nextAccount).length > 0) {
await tx
.update(accounts)
.set(nextAccount)
.where(
and(
eq(accounts.provider, "discord"),
eq(accounts.providerAccountId, discordId)
)
);
}
if (!user) throw new Error("Failed to reconcile Discord user");
return user;
});
}