Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 19:36:14 -04:00
co-authored by Cursor
commit 27e2f196eb
149 changed files with 13847 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@album/contracts",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.25.67"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
+216
View File
@@ -0,0 +1,216 @@
import { z } from "zod";
export const eventStatusSchema = z.enum(["draft", "published", "closed"]);
export const groupRoleSchema = z.enum(["owner", "member"]);
export const eventRoleSchema = z.enum([
"owner",
"manager",
"moderator",
"viewer",
]);
export const platformRoleSchema = z.enum([
"super_admin",
"admin",
"moderator",
"viewer",
]);
export const eventCreatePolicySchema = z.enum(["open", "invite", "admin_only"]);
export const entitlementSourceSchema = z.enum([
"signup_default",
"invite",
"code",
"platform",
]);
export const photoProcessingStatusSchema = z.enum([
"uploading",
"processing",
"ready",
"failed",
]);
export const photoVisibilitySchema = z.enum([
"pending",
"public",
"hidden",
"private",
"rejected",
]);
export const photoJobStatusSchema = z.enum([
"pending",
"processing",
"completed",
"failed",
]);
export const allowedImageTypes = [
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
] as const;
export const allowedImageTypeSchema = z.enum(allowedImageTypes);
export const MAX_PHOTO_BYTES = 25 * 1024 * 1024;
export const eventSlugSchema = z
.string()
.min(3)
.max(64)
.regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Use lowercase letters, numbers, and hyphens",
);
export const contributorNameSchema = z
.string()
.trim()
.max(80)
.optional()
.transform((value) => {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
});
export const guestEmailSchema = z
.string()
.trim()
.email()
.max(200)
.optional()
.or(z.literal(""));
export const guestNoteSchema = z.string().trim().max(2000).optional();
export const createEventInputSchema = z.object({
groupId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(120),
slug: eventSlugSchema.optional(),
description: z.string().trim().max(2000).optional(),
startsAt: z.coerce.date().optional().nullable(),
endsAt: z.coerce.date().optional().nullable(),
inviteCode: z.string().trim().max(80).optional(),
});
export const updateEventInputSchema = z.object({
eventId: z.string().uuid(),
title: z.string().trim().min(1).max(120).optional(),
slug: eventSlugSchema.optional(),
description: z.string().trim().max(2000).nullable().optional(),
startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(),
status: eventStatusSchema.optional(),
listed: z.boolean().optional(),
uploadEnabled: z.boolean().optional(),
});
export const ensureGuestInputSchema = z.object({
eventSlug: eventSlugSchema,
displayName: contributorNameSchema,
email: z
.string()
.trim()
.max(200)
.optional()
.transform((value) => {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
})
.pipe(z.string().email().optional()),
notifyWhenReady: z.boolean().optional(),
note: guestNoteSchema,
});
export const startSubmissionInputSchema = z.object({
eventSlug: eventSlugSchema,
});
export const createPhotoInputSchema = z.object({
eventSlug: eventSlugSchema,
submissionId: z.string().uuid(),
contentType: allowedImageTypeSchema,
fileName: z.string().min(1).max(255),
byteSize: z.number().int().positive().max(MAX_PHOTO_BYTES),
});
export const completePhotoInputSchema = z.object({
photoId: z.string().uuid(),
});
export const moderatePhotoInputSchema = z.object({
photoId: z.string().uuid(),
visibility: photoVisibilitySchema,
});
export const moderateSubmissionInputSchema = z.object({
submissionId: z.string().uuid(),
visibility: photoVisibilitySchema,
});
export const updateDeploymentSettingsInputSchema = z.object({
openSignup: z.boolean().optional(),
eventCreatePolicy: eventCreatePolicySchema.optional(),
defaultEventLimit: z.number().int().min(0).max(1000).optional(),
});
export const createEmailInviteInputSchema = z.object({
email: z.string().trim().email().max(200),
groupId: z.string().uuid().optional(),
eventId: z.string().uuid().optional(),
groupRole: groupRoleSchema.optional(),
eventRole: eventRoleSchema.optional(),
grantUnlimitedEvents: z.boolean().optional(),
grantEventLimit: z.number().int().min(1).max(1000).optional(),
grantComplimentary: z.boolean().optional(),
});
export const createInviteCodeInputSchema = z.object({
reusable: z.boolean().optional(),
maxUses: z.number().int().min(1).max(10000).optional(),
groupId: z.string().uuid().optional(),
eventId: z.string().uuid().optional(),
groupRole: groupRoleSchema.optional(),
eventRole: eventRoleSchema.optional(),
grantUnlimitedEvents: z.boolean().optional(),
grantEventLimit: z.number().int().min(1).max(1000).optional(),
grantComplimentary: z.boolean().optional(),
expiresAt: z.coerce.date().optional().nullable(),
});
export const redeemInviteInputSchema = z.object({
token: z.string().trim().min(4).max(120),
});
export const grantEntitlementInputSchema = z.object({
groupId: z.string().uuid(),
eventLimit: z.number().int().min(1).max(10000).nullable().optional(),
expiresAt: z.coerce.date().optional().nullable(),
complimentary: z.boolean().optional(),
});
export const setPlatformRoleInputSchema = z.object({
userId: z.string().min(1),
role: platformRoleSchema.nullable(),
});
export const setEventMemberInputSchema = z.object({
eventId: z.string().uuid(),
userId: z.string().min(1).optional(),
email: z.string().trim().email().optional(),
role: eventRoleSchema,
});
export const setGroupMemberInputSchema = z.object({
groupId: z.string().uuid(),
userId: z.string().min(1).optional(),
email: z.string().trim().email().optional(),
role: groupRoleSchema,
});
export type EventStatus = z.infer<typeof eventStatusSchema>;
export type GroupRole = z.infer<typeof groupRoleSchema>;
export type EventRole = z.infer<typeof eventRoleSchema>;
export type PlatformRole = z.infer<typeof platformRoleSchema>;
export type EventCreatePolicy = z.infer<typeof eventCreatePolicySchema>;
export type PhotoProcessingStatus = z.infer<typeof photoProcessingStatusSchema>;
export type PhotoVisibility = z.infer<typeof photoVisibilitySchema>;
export type AllowedImageType = z.infer<typeof allowedImageTypeSchema>;
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
const url =
process.env.DATABASE_URL ??
"postgres://album:album@localhost:5439/album";
export default defineConfig({
schema: ["./src/schema.ts", "./src/auth-schema.ts"],
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url,
},
});
@@ -0,0 +1,113 @@
CREATE TYPE "public"."event_status" AS ENUM('draft', 'published', 'closed');--> statement-breakpoint
CREATE TYPE "public"."photo_job_status" AS ENUM('pending', 'processing', 'completed', 'failed');--> statement-breakpoint
CREATE TYPE "public"."photo_status" AS ENUM('uploading', 'processing', 'pending', 'approved', 'rejected');--> statement-breakpoint
CREATE TABLE "events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"host_user_id" text NOT NULL,
"slug" text NOT NULL,
"title" text NOT NULL,
"description" text,
"starts_at" timestamp with time zone,
"ends_at" timestamp with time zone,
"status" "event_status" DEFAULT 'draft' NOT NULL,
"upload_enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "photo_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"photo_id" uuid NOT NULL,
"kind" text DEFAULT 'transcode' NOT NULL,
"status" "photo_job_status" DEFAULT 'pending' NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "photos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"contributor_name" text,
"status" "photo_status" DEFAULT 'uploading' NOT NULL,
"original_key" text NOT NULL,
"display_key" text,
"thumb_key" text,
"content_type" text NOT NULL,
"byte_size" integer DEFAULT 0 NOT NULL,
"width" integer,
"height" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "rate_limit_buckets" (
"key" text PRIMARY KEY NOT NULL,
"count" integer NOT NULL,
"reset_at" timestamp with time zone NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp with time zone,
"refresh_token_expires_at" timestamp with time zone,
"scope" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"token" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "events" ADD CONSTRAINT "events_host_user_id_user_id_fk" FOREIGN KEY ("host_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "photo_jobs" ADD CONSTRAINT "photo_jobs_photo_id_photos_id_fk" FOREIGN KEY ("photo_id") REFERENCES "public"."photos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "photos" ADD CONSTRAINT "photos_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "events_slug_idx" ON "events" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "events_host_user_id_idx" ON "events" USING btree ("host_user_id");--> statement-breakpoint
CREATE INDEX "photo_jobs_status_idx" ON "photo_jobs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "photo_jobs_photo_id_idx" ON "photo_jobs" USING btree ("photo_id");--> statement-breakpoint
CREATE INDEX "photos_event_id_idx" ON "photos" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "photos_event_status_idx" ON "photos" USING btree ("event_id","status");--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
@@ -0,0 +1,207 @@
CREATE TYPE "public"."group_role" AS ENUM('owner', 'member');--> statement-breakpoint
CREATE TYPE "public"."event_role" AS ENUM('owner', 'manager', 'moderator', 'viewer');--> statement-breakpoint
CREATE TYPE "public"."platform_role" AS ENUM('super_admin', 'admin', 'moderator', 'viewer');--> statement-breakpoint
CREATE TYPE "public"."event_create_policy" AS ENUM('open', 'invite', 'admin_only');--> statement-breakpoint
CREATE TYPE "public"."entitlement_source" AS ENUM('signup_default', 'invite', 'code', 'platform');--> statement-breakpoint
CREATE TYPE "public"."invite_kind" AS ENUM('email', 'code');--> statement-breakpoint
CREATE TYPE "public"."invite_status" AS ENUM('pending', 'accepted', 'revoked', 'expired');--> statement-breakpoint
CREATE TYPE "public"."photo_processing_status" AS ENUM('uploading', 'processing', 'ready', 'failed');--> statement-breakpoint
CREATE TYPE "public"."photo_visibility" AS ENUM('pending', 'public', 'hidden', 'private', 'rejected');--> statement-breakpoint
CREATE TABLE "groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"created_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "group_memberships" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid NOT NULL,
"user_id" text NOT NULL,
"role" "group_role" DEFAULT 'member' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "event_memberships" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"user_id" text NOT NULL,
"role" "event_role" DEFAULT 'viewer' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "platform_administrators" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"role" "platform_role" DEFAULT 'admin' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "deployment_settings" (
"id" text PRIMARY KEY DEFAULT 'default' NOT NULL,
"open_signup" boolean DEFAULT true NOT NULL,
"event_create_policy" "event_create_policy" DEFAULT 'open' NOT NULL,
"default_event_limit" integer DEFAULT 1 NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "entitlements" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid NOT NULL,
"event_limit" integer,
"expires_at" timestamp with time zone,
"complimentary" boolean DEFAULT false NOT NULL,
"source" "entitlement_source" DEFAULT 'signup_default' NOT NULL,
"granted_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "invites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"kind" "invite_kind" NOT NULL,
"status" "invite_status" DEFAULT 'pending' NOT NULL,
"email" text,
"token_hash" text NOT NULL,
"reusable" boolean DEFAULT false NOT NULL,
"max_uses" integer DEFAULT 1 NOT NULL,
"used_count" integer DEFAULT 0 NOT NULL,
"group_id" uuid,
"event_id" uuid,
"group_role" "group_role",
"event_role" "event_role",
"grant_unlimited_events" boolean DEFAULT false NOT NULL,
"grant_event_limit" integer,
"grant_complimentary" boolean DEFAULT false NOT NULL,
"created_by_user_id" text,
"accepted_by_user_id" text,
"expires_at" timestamp with time zone,
"accepted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "guests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"display_name" text,
"email" text,
"notify_when_ready" boolean DEFAULT false NOT NULL,
"notified_at" timestamp with time zone,
"note" text,
"token_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "submissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"guest_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE "audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"group_id" uuid,
"event_id" uuid,
"actor_user_id" text,
"action" text NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "group_id" uuid;--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "listed" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "gallery_released_at" timestamp with time zone;--> statement-breakpoint
INSERT INTO "groups" ("id", "name", "slug", "created_by_user_id")
SELECT gen_random_uuid(), COALESCE(u.name, 'Group') || '''s group', 'group-' || substr(replace(e.host_user_id, '-', ''), 1, 16), e.host_user_id
FROM (SELECT DISTINCT host_user_id FROM events) e
LEFT JOIN "user" u ON u.id = e.host_user_id
WHERE NOT EXISTS (SELECT 1 FROM groups g WHERE g.created_by_user_id = e.host_user_id);--> statement-breakpoint
UPDATE events SET group_id = g.id
FROM groups g
WHERE g.created_by_user_id = events.host_user_id;--> statement-breakpoint
INSERT INTO group_memberships (group_id, user_id, role)
SELECT g.id, g.created_by_user_id, 'owner'
FROM groups g
WHERE g.created_by_user_id IS NOT NULL;--> statement-breakpoint
INSERT INTO event_memberships (event_id, user_id, role)
SELECT e.id, e.host_user_id, 'owner' FROM events e;--> statement-breakpoint
INSERT INTO entitlements (group_id, event_limit, complimentary, source)
SELECT g.id, 1, false, 'signup_default' FROM groups g;--> statement-breakpoint
ALTER TABLE "events" ALTER COLUMN "group_id" SET NOT NULL;--> statement-breakpoint
DROP INDEX IF EXISTS "events_host_user_id_idx";--> statement-breakpoint
ALTER TABLE "events" DROP COLUMN "host_user_id";--> statement-breakpoint
ALTER TABLE "photos" ADD COLUMN "submission_id" uuid;--> statement-breakpoint
ALTER TABLE "photos" ADD COLUMN "processing_status" "photo_processing_status" DEFAULT 'uploading' NOT NULL;--> statement-breakpoint
ALTER TABLE "photos" ADD COLUMN "visibility" "photo_visibility" DEFAULT 'pending' NOT NULL;--> statement-breakpoint
INSERT INTO guests (id, event_id, display_name, token_hash, notify_when_ready)
SELECT gen_random_uuid(), e.id, NULL, 'migrated-' || replace(e.id::text, '-', ''), false
FROM events e
WHERE EXISTS (SELECT 1 FROM photos p WHERE p.event_id = e.id);--> statement-breakpoint
INSERT INTO submissions (id, event_id, guest_id)
SELECT gen_random_uuid(), g.event_id, g.id
FROM guests g
WHERE g.token_hash LIKE 'migrated-%';--> statement-breakpoint
UPDATE photos p SET submission_id = s.id
FROM submissions s
WHERE s.event_id = p.event_id;--> statement-breakpoint
UPDATE photos SET
processing_status = CASE
WHEN status = 'uploading' THEN 'uploading'::photo_processing_status
WHEN status = 'processing' THEN 'processing'::photo_processing_status
ELSE 'ready'::photo_processing_status
END,
visibility = CASE
WHEN status = 'approved' THEN 'public'::photo_visibility
WHEN status = 'rejected' THEN 'rejected'::photo_visibility
ELSE 'pending'::photo_visibility
END;--> statement-breakpoint
ALTER TABLE "photos" ALTER COLUMN "submission_id" SET NOT NULL;--> statement-breakpoint
DROP INDEX IF EXISTS "photos_event_status_idx";--> statement-breakpoint
ALTER TABLE "photos" DROP COLUMN "contributor_name";--> statement-breakpoint
ALTER TABLE "photos" DROP COLUMN "status";--> statement-breakpoint
DROP TYPE "public"."photo_status";--> statement-breakpoint
INSERT INTO deployment_settings (id) VALUES ('default') ON CONFLICT (id) DO NOTHING;--> statement-breakpoint
ALTER TABLE "groups" ADD CONSTRAINT "groups_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "group_memberships" ADD CONSTRAINT "group_memberships_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "group_memberships" ADD CONSTRAINT "group_memberships_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "event_memberships" ADD CONSTRAINT "event_memberships_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "event_memberships" ADD CONSTRAINT "event_memberships_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "platform_administrators" ADD CONSTRAINT "platform_administrators_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitlements" ADD CONSTRAINT "entitlements_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitlements" ADD CONSTRAINT "entitlements_granted_by_user_id_user_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invites" ADD CONSTRAINT "invites_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invites" ADD CONSTRAINT "invites_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invites" ADD CONSTRAINT "invites_accepted_by_user_id_user_id_fk" FOREIGN KEY ("accepted_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "guests" ADD CONSTRAINT "guests_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "submissions" ADD CONSTRAINT "submissions_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "submissions" ADD CONSTRAINT "submissions_guest_id_guests_id_fk" FOREIGN KEY ("guest_id") REFERENCES "public"."guests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "photos" ADD CONSTRAINT "photos_submission_id_submissions_id_fk" FOREIGN KEY ("submission_id") REFERENCES "public"."submissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "events" ADD CONSTRAINT "events_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "groups_slug_idx" ON "groups" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "groups_created_by_user_id_idx" ON "groups" USING btree ("created_by_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "group_memberships_group_user_idx" ON "group_memberships" USING btree ("group_id","user_id");--> statement-breakpoint
CREATE INDEX "group_memberships_user_idx" ON "group_memberships" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "event_memberships_event_user_idx" ON "event_memberships" USING btree ("event_id","user_id");--> statement-breakpoint
CREATE INDEX "event_memberships_user_idx" ON "event_memberships" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "platform_administrators_user_idx" ON "platform_administrators" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "events_group_id_idx" ON "events" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "events_listed_idx" ON "events" USING btree ("listed","status");--> statement-breakpoint
CREATE INDEX "entitlements_group_id_idx" ON "entitlements" USING btree ("group_id");--> statement-breakpoint
CREATE UNIQUE INDEX "invites_token_hash_idx" ON "invites" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "invites_email_idx" ON "invites" USING btree ("email");--> statement-breakpoint
CREATE INDEX "invites_group_id_idx" ON "invites" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "invites_event_id_idx" ON "invites" USING btree ("event_id");--> statement-breakpoint
CREATE UNIQUE INDEX "guests_event_token_idx" ON "guests" USING btree ("event_id","token_hash");--> statement-breakpoint
CREATE INDEX "guests_event_id_idx" ON "guests" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "submissions_event_id_idx" ON "submissions" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "submissions_guest_id_idx" ON "submissions" USING btree ("guest_id");--> statement-breakpoint
CREATE INDEX "photos_submission_id_idx" ON "photos" USING btree ("submission_id");--> statement-breakpoint
CREATE INDEX "photos_event_visibility_idx" ON "photos" USING btree ("event_id","visibility");--> statement-breakpoint
CREATE INDEX "audit_events_group_id_idx" ON "audit_events" USING btree ("group_id");--> statement-breakpoint
CREATE INDEX "audit_events_event_id_idx" ON "audit_events" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "audit_events_created_at_idx" ON "audit_events" USING btree ("created_at");
@@ -0,0 +1,834 @@
{
"id": "79bd8f10-412e-493f-a541-c42d83437e0f",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.events": {
"name": "events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"host_user_id": {
"name": "host_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"starts_at": {
"name": "starts_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"ends_at": {
"name": "ends_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "event_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"upload_enabled": {
"name": "upload_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": 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": {
"events_slug_idx": {
"name": "events_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"events_host_user_id_idx": {
"name": "events_host_user_id_idx",
"columns": [
{
"expression": "host_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"events_host_user_id_user_id_fk": {
"name": "events_host_user_id_user_id_fk",
"tableFrom": "events",
"tableTo": "user",
"columnsFrom": [
"host_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.photo_jobs": {
"name": "photo_jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"photo_id": {
"name": "photo_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'transcode'"
},
"status": {
"name": "status",
"type": "photo_job_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"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": {
"photo_jobs_status_idx": {
"name": "photo_jobs_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"photo_jobs_photo_id_idx": {
"name": "photo_jobs_photo_id_idx",
"columns": [
{
"expression": "photo_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"photo_jobs_photo_id_photos_id_fk": {
"name": "photo_jobs_photo_id_photos_id_fk",
"tableFrom": "photo_jobs",
"tableTo": "photos",
"columnsFrom": [
"photo_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.photos": {
"name": "photos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"event_id": {
"name": "event_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"contributor_name": {
"name": "contributor_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "photo_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'uploading'"
},
"original_key": {
"name": "original_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_key": {
"name": "display_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"thumb_key": {
"name": "thumb_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"byte_size": {
"name": "byte_size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"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": {
"photos_event_id_idx": {
"name": "photos_event_id_idx",
"columns": [
{
"expression": "event_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"photos_event_status_idx": {
"name": "photos_event_status_idx",
"columns": [
{
"expression": "event_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"photos_event_id_events_id_fk": {
"name": "photos_event_id_events_id_fk",
"tableFrom": "photos",
"tableTo": "events",
"columnsFrom": [
"event_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.rate_limit_buckets": {
"name": "rate_limit_buckets",
"schema": "",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reset_at": {
"name": "reset_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"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
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"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
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"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": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"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": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.event_status": {
"name": "event_status",
"schema": "public",
"values": [
"draft",
"published",
"closed"
]
},
"public.photo_job_status": {
"name": "photo_job_status",
"schema": "public",
"values": [
"pending",
"processing",
"completed",
"failed"
]
},
"public.photo_status": {
"name": "photo_status",
"schema": "public",
"values": [
"uploading",
"processing",
"pending",
"approved",
"rejected"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1788816949899,
"tag": "0000_yummy_whizzer",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1788819000000,
"tag": "0001_groups_roles_guests",
"breakpoints": true
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@album/database",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"generate": "drizzle-kit generate",
"migrate": "bun src/migrate.ts",
"push": "drizzle-kit push",
"studio": "drizzle-kit studio",
"seed": "bun src/seed.ts"
},
"dependencies": {
"dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.7"
},
"devDependencies": {
"drizzle-kit": "^0.31.1",
"typescript": "^5.8.3"
}
}
+105
View File
@@ -0,0 +1,105 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", {
withTimezone: true,
}),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
withTimezone: true,
}),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
+56
View File
@@ -0,0 +1,56 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as appSchema from "./schema";
import * as authSchema from "./auth-schema";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const schema = { ...appSchema, ...authSchema };
type PostgresClient = ReturnType<typeof postgres>;
type Database = ReturnType<typeof drizzle<typeof schema>>;
export type { Database };
const globalDatabase = globalThis as typeof globalThis & {
albumPostgresClient?: PostgresClient;
albumDatabase?: Database;
};
let client = globalDatabase.albumPostgresClient;
let database = globalDatabase.albumDatabase;
export function getDb() {
if (database) return database;
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("DATABASE_URL is required for database operations");
}
client = postgres(url, {
max: process.env.NODE_ENV === "production" ? 10 : 3,
idle_timeout: 20,
});
database = drizzle(client, { schema });
if (process.env.NODE_ENV !== "production") {
globalDatabase.albumPostgresClient = client;
globalDatabase.albumDatabase = database;
}
return database;
}
export async function closeDb() {
await client?.end();
client = undefined;
database = undefined;
globalDatabase.albumPostgresClient = undefined;
globalDatabase.albumDatabase = undefined;
}
+3
View File
@@ -0,0 +1,3 @@
export { closeDb, getDb, type Database } from "./db";
export * from "./schema";
export * from "./auth-schema";
+16
View File
@@ -0,0 +1,16 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const url =
process.env.DATABASE_URL ??
"postgres://album:album@localhost:5439/album";
const client = postgres(url, { max: 1 });
const db = drizzle(client);
await migrate(db, { migrationsFolder: "./drizzle" });
await client.end();
console.log("Migrations applied successfully.");
+491
View File
@@ -0,0 +1,491 @@
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { relations } from "drizzle-orm";
import { user } from "./auth-schema";
const timestamps = {
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
};
export const eventStatus = pgEnum("event_status", [
"draft",
"published",
"closed",
]);
export const groupRole = pgEnum("group_role", ["owner", "member"]);
export const eventRole = pgEnum("event_role", [
"owner",
"manager",
"moderator",
"viewer",
]);
export const platformRole = pgEnum("platform_role", [
"super_admin",
"admin",
"moderator",
"viewer",
]);
export const eventCreatePolicy = pgEnum("event_create_policy", [
"open",
"invite",
"admin_only",
]);
export const entitlementSource = pgEnum("entitlement_source", [
"signup_default",
"invite",
"code",
"platform",
]);
export const inviteKind = pgEnum("invite_kind", ["email", "code"]);
export const inviteStatus = pgEnum("invite_status", [
"pending",
"accepted",
"revoked",
"expired",
]);
export const photoProcessingStatus = pgEnum("photo_processing_status", [
"uploading",
"processing",
"ready",
"failed",
]);
export const photoVisibility = pgEnum("photo_visibility", [
"pending",
"public",
"hidden",
"private",
"rejected",
]);
export const photoJobStatus = pgEnum("photo_job_status", [
"pending",
"processing",
"completed",
"failed",
]);
export const groups = pgTable(
"groups",
{
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull(),
createdByUserId: text("created_by_user_id").references(() => user.id, {
onDelete: "set null",
}),
...timestamps,
},
(table) => [
uniqueIndex("groups_slug_idx").on(table.slug),
index("groups_created_by_user_id_idx").on(table.createdByUserId),
],
);
export const groupMemberships = pgTable(
"group_memberships",
{
id: uuid("id").defaultRandom().primaryKey(),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: groupRole("role").notNull().default("member"),
...timestamps,
},
(table) => [
uniqueIndex("group_memberships_group_user_idx").on(
table.groupId,
table.userId,
),
index("group_memberships_user_idx").on(table.userId),
],
);
export const events = pgTable(
"events",
{
id: uuid("id").defaultRandom().primaryKey(),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
slug: text("slug").notNull(),
title: text("title").notNull(),
description: text("description"),
startsAt: timestamp("starts_at", { withTimezone: true }),
endsAt: timestamp("ends_at", { withTimezone: true }),
status: eventStatus("status").notNull().default("draft"),
listed: boolean("listed").notNull().default(false),
uploadEnabled: boolean("upload_enabled").notNull().default(true),
galleryReleasedAt: timestamp("gallery_released_at", { withTimezone: true }),
...timestamps,
},
(table) => [
uniqueIndex("events_slug_idx").on(table.slug),
index("events_group_id_idx").on(table.groupId),
index("events_listed_idx").on(table.listed, table.status),
],
);
export const eventMemberships = pgTable(
"event_memberships",
{
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id")
.notNull()
.references(() => events.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: eventRole("role").notNull().default("viewer"),
...timestamps,
},
(table) => [
uniqueIndex("event_memberships_event_user_idx").on(
table.eventId,
table.userId,
),
index("event_memberships_user_idx").on(table.userId),
],
);
export const platformAdministrators = pgTable(
"platform_administrators",
{
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: platformRole("role").notNull().default("admin"),
...timestamps,
},
(table) => [uniqueIndex("platform_administrators_user_idx").on(table.userId)],
);
export const deploymentSettings = pgTable("deployment_settings", {
id: text("id").primaryKey().default("default"),
openSignup: boolean("open_signup").notNull().default(true),
eventCreatePolicy: eventCreatePolicy("event_create_policy")
.notNull()
.default("open"),
defaultEventLimit: integer("default_event_limit").notNull().default(1),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const entitlements = pgTable(
"entitlements",
{
id: uuid("id").defaultRandom().primaryKey(),
groupId: uuid("group_id")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
eventLimit: integer("event_limit"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
complimentary: boolean("complimentary").notNull().default(false),
source: entitlementSource("source").notNull().default("signup_default"),
grantedByUserId: text("granted_by_user_id").references(() => user.id, {
onDelete: "set null",
}),
...timestamps,
},
(table) => [index("entitlements_group_id_idx").on(table.groupId)],
);
export const invites = pgTable(
"invites",
{
id: uuid("id").defaultRandom().primaryKey(),
kind: inviteKind("kind").notNull(),
status: inviteStatus("status").notNull().default("pending"),
email: text("email"),
tokenHash: text("token_hash").notNull(),
reusable: boolean("reusable").notNull().default(false),
maxUses: integer("max_uses").notNull().default(1),
usedCount: integer("used_count").notNull().default(0),
groupId: uuid("group_id").references(() => groups.id, {
onDelete: "cascade",
}),
eventId: uuid("event_id").references(() => events.id, {
onDelete: "cascade",
}),
groupRole: groupRole("group_role"),
eventRole: eventRole("event_role"),
grantUnlimitedEvents: boolean("grant_unlimited_events")
.notNull()
.default(false),
grantEventLimit: integer("grant_event_limit"),
grantComplimentary: boolean("grant_complimentary").notNull().default(false),
createdByUserId: text("created_by_user_id").references(() => user.id, {
onDelete: "set null",
}),
acceptedByUserId: text("accepted_by_user_id").references(() => user.id, {
onDelete: "set null",
}),
expiresAt: timestamp("expires_at", { withTimezone: true }),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
...timestamps,
},
(table) => [
uniqueIndex("invites_token_hash_idx").on(table.tokenHash),
index("invites_email_idx").on(table.email),
index("invites_group_id_idx").on(table.groupId),
index("invites_event_id_idx").on(table.eventId),
],
);
export const guests = pgTable(
"guests",
{
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id")
.notNull()
.references(() => events.id, { onDelete: "cascade" }),
displayName: text("display_name"),
email: text("email"),
notifyWhenReady: boolean("notify_when_ready").notNull().default(false),
notifiedAt: timestamp("notified_at", { withTimezone: true }),
note: text("note"),
tokenHash: text("token_hash").notNull(),
...timestamps,
},
(table) => [
uniqueIndex("guests_event_token_idx").on(table.eventId, table.tokenHash),
index("guests_event_id_idx").on(table.eventId),
],
);
export const submissions = pgTable(
"submissions",
{
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id")
.notNull()
.references(() => events.id, { onDelete: "cascade" }),
guestId: uuid("guest_id")
.notNull()
.references(() => guests.id, { onDelete: "cascade" }),
...timestamps,
},
(table) => [
index("submissions_event_id_idx").on(table.eventId),
index("submissions_guest_id_idx").on(table.guestId),
],
);
export const photos = pgTable(
"photos",
{
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id")
.notNull()
.references(() => events.id, { onDelete: "cascade" }),
submissionId: uuid("submission_id")
.notNull()
.references(() => submissions.id, { onDelete: "cascade" }),
processingStatus: photoProcessingStatus("processing_status")
.notNull()
.default("uploading"),
visibility: photoVisibility("visibility").notNull().default("pending"),
originalKey: text("original_key").notNull(),
displayKey: text("display_key"),
thumbKey: text("thumb_key"),
contentType: text("content_type").notNull(),
byteSize: integer("byte_size").notNull().default(0),
width: integer("width"),
height: integer("height"),
...timestamps,
},
(table) => [
index("photos_event_id_idx").on(table.eventId),
index("photos_submission_id_idx").on(table.submissionId),
index("photos_event_visibility_idx").on(table.eventId, table.visibility),
],
);
export const photoJobs = pgTable(
"photo_jobs",
{
id: uuid("id").defaultRandom().primaryKey(),
photoId: uuid("photo_id")
.notNull()
.references(() => photos.id, { onDelete: "cascade" }),
kind: text("kind").notNull().default("transcode"),
status: photoJobStatus("status").notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
lastError: text("last_error"),
...timestamps,
},
(table) => [
index("photo_jobs_status_idx").on(table.status),
index("photo_jobs_photo_id_idx").on(table.photoId),
],
);
export const auditEvents = pgTable(
"audit_events",
{
id: uuid("id").defaultRandom().primaryKey(),
groupId: uuid("group_id").references(() => groups.id, {
onDelete: "set null",
}),
eventId: uuid("event_id").references(() => events.id, {
onDelete: "set null",
}),
actorUserId: text("actor_user_id").references(() => user.id, {
onDelete: "set null",
}),
action: text("action").notNull(),
subjectType: text("subject_type").notNull(),
subjectId: text("subject_id").notNull(),
metadata: jsonb("metadata")
.$type<Record<string, string | number | boolean | null>>()
.notNull()
.default(sql`'{}'::jsonb`),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
index("audit_events_group_id_idx").on(table.groupId),
index("audit_events_event_id_idx").on(table.eventId),
index("audit_events_created_at_idx").on(table.createdAt),
],
);
export const rateLimitBuckets = pgTable("rate_limit_buckets", {
key: text("key").primaryKey(),
count: integer("count").notNull(),
resetAt: timestamp("reset_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const groupRelations = relations(groups, ({ many }) => ({
memberships: many(groupMemberships),
events: many(events),
entitlements: many(entitlements),
}));
export const groupMembershipRelations = relations(
groupMemberships,
({ one }) => ({
group: one(groups, {
fields: [groupMemberships.groupId],
references: [groups.id],
}),
user: one(user, {
fields: [groupMemberships.userId],
references: [user.id],
}),
}),
);
export const eventRelations = relations(events, ({ one, many }) => ({
group: one(groups, {
fields: [events.groupId],
references: [groups.id],
}),
memberships: many(eventMemberships),
photos: many(photos),
guests: many(guests),
submissions: many(submissions),
}));
export const eventMembershipRelations = relations(
eventMemberships,
({ one }) => ({
event: one(events, {
fields: [eventMemberships.eventId],
references: [events.id],
}),
user: one(user, {
fields: [eventMemberships.userId],
references: [user.id],
}),
}),
);
export const guestRelations = relations(guests, ({ one, many }) => ({
event: one(events, {
fields: [guests.eventId],
references: [events.id],
}),
submissions: many(submissions),
}));
export const submissionRelations = relations(submissions, ({ one, many }) => ({
event: one(events, {
fields: [submissions.eventId],
references: [events.id],
}),
guest: one(guests, {
fields: [submissions.guestId],
references: [guests.id],
}),
photos: many(photos),
}));
export const photoRelations = relations(photos, ({ one, many }) => ({
event: one(events, {
fields: [photos.eventId],
references: [events.id],
}),
submission: one(submissions, {
fields: [photos.submissionId],
references: [submissions.id],
}),
jobs: many(photoJobs),
}));
export const photoJobRelations = relations(photoJobs, ({ one }) => ({
photo: one(photos, {
fields: [photoJobs.photoId],
references: [photos.id],
}),
}));
export const platformAdministratorRelations = relations(
platformAdministrators,
({ one }) => ({
user: one(user, {
fields: [platformAdministrators.userId],
references: [user.id],
}),
}),
);
export const entitlementRelations = relations(entitlements, ({ one }) => ({
group: one(groups, {
fields: [entitlements.groupId],
references: [groups.id],
}),
}));
+173
View File
@@ -0,0 +1,173 @@
import "dotenv/config";
import { and, eq } from "drizzle-orm";
import {
closeDb,
deploymentSettings,
entitlements,
eventMemberships,
events,
getDb,
groupMemberships,
groups,
platformAdministrators,
user,
} from "./index";
const db = getDb();
async function userByEmail(email: string) {
const [row] = await db.select().from(user).where(eq(user.email, email)).limit(1);
return row ?? null;
}
const admin = await userByEmail("admin@example.com");
const host = await userByEmail("host@example.com");
const partner = await userByEmail("partner@example.com");
const manager = await userByEmail("manager@example.com");
if (!admin || !host || !partner || !manager) {
console.info(
"Seed users missing. Run bun run auth:seed, then bun run db:seed again.",
);
await closeDb();
process.exit(0);
}
await db
.insert(deploymentSettings)
.values({ id: "default" })
.onConflictDoNothing();
const [existingAdmin] = await db
.select()
.from(platformAdministrators)
.where(eq(platformAdministrators.userId, admin.id))
.limit(1);
if (!existingAdmin) {
await db.insert(platformAdministrators).values({
userId: admin.id,
role: "super_admin",
});
}
let [group] = await db
.select()
.from(groups)
.where(eq(groups.slug, "demo-family"))
.limit(1);
if (!group) {
[group] = await db
.insert(groups)
.values({
name: "Demo family",
slug: "demo-family",
createdByUserId: host.id,
})
.returning();
}
if (group) {
for (const member of [
{ userId: host.id, role: "owner" as const },
{ userId: partner.id, role: "owner" as const },
{ userId: manager.id, role: "member" as const },
]) {
const [existing] = await db
.select()
.from(groupMemberships)
.where(
and(
eq(groupMemberships.groupId, group.id),
eq(groupMemberships.userId, member.userId),
),
)
.limit(1);
if (!existing) {
await db.insert(groupMemberships).values({
groupId: group.id,
userId: member.userId,
role: member.role,
});
}
}
const [entitlement] = await db
.select()
.from(entitlements)
.where(eq(entitlements.groupId, group.id))
.limit(1);
if (!entitlement) {
await db.insert(entitlements).values({
groupId: group.id,
eventLimit: null,
complimentary: true,
source: "platform",
grantedByUserId: admin.id,
});
}
let [event] = await db
.select()
.from(events)
.where(eq(events.slug, "demo"))
.limit(1);
if (!event) {
[event] = await db
.insert(events)
.values({
groupId: group.id,
slug: "demo",
title: "Demo gathering",
description:
"A sample event so you can try guest uploads and host moderation.",
status: "published",
listed: true,
uploadEnabled: true,
galleryReleasedAt: new Date(),
startsAt: new Date(),
})
.returning();
console.info("Seeded published event /e/demo");
} else {
await db
.update(events)
.set({
groupId: group.id,
listed: true,
galleryReleasedAt: event.galleryReleasedAt ?? new Date(),
status: "published",
updatedAt: new Date(),
})
.where(eq(events.id, event.id));
console.info("Demo event already exists");
}
if (event) {
for (const member of [
{ userId: host.id, role: "owner" as const },
{ userId: partner.id, role: "owner" as const },
{ userId: manager.id, role: "manager" as const },
]) {
const [existing] = await db
.select()
.from(eventMemberships)
.where(
and(
eq(eventMemberships.eventId, event.id),
eq(eventMemberships.userId, member.userId),
),
)
.limit(1);
if (!existing) {
await db.insert(eventMemberships).values({
eventId: event.id,
userId: member.userId,
role: member.role,
});
}
}
}
}
console.info("Seeded demo group, two owners, a manager, and platform super-admin.");
await closeDb();
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts", "drizzle.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@album/email",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"nodemailer": "^9.0.3",
"resend": "^6.18.0"
},
"devDependencies": {
"@types/nodemailer": "^8.0.1",
"typescript": "^5.8.3"
}
}
+216
View File
@@ -0,0 +1,216 @@
import { createHash } from "node:crypto";
import nodemailer from "nodemailer";
import { Resend } from "resend";
const PRIMARY = "#8b5a4a";
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function referenceHash(value: string) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function chrome(bodyHtml: string) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="margin:0;padding:24px 12px;background:#f6f1ea">
<div style="max-width:560px;margin:0 auto;color:#2c2416;background:#fff;font-family:Georgia,serif">
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
<strong style="font-size:22px;letter-spacing:.04em">Vellum</strong>
</div>
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0">
${bodyHtml}
</div>
<div style="padding:18px 24px;text-align:center;background:#faf7f2;border:1px solid #e8dfd2;border-top:0">
<p style="margin:0;font-size:11px;color:#7a6e5e">Hadlock Technologies LLC</p>
</div>
</div>
</body>
</html>`;
}
export function getEmailReadiness() {
const provider = process.env.EMAIL_PROVIDER ?? "resend";
const missing: string[] = [];
if (!process.env.EMAIL_FROM && !process.env.RESEND_FROM) {
missing.push("EMAIL_FROM");
}
if (provider === "resend") {
if (!process.env.RESEND_API_KEY) missing.push("RESEND_API_KEY");
} else if (provider === "mailpit" || provider === "smtp") {
if (provider === "mailpit" && process.env.NODE_ENV === "production") {
missing.push("EMAIL_PROVIDER (mailpit is development-only)");
}
if (!process.env.SMTP_HOST) missing.push("SMTP_HOST");
if (!process.env.SMTP_PORT) missing.push("SMTP_PORT");
} else {
missing.push("EMAIL_PROVIDER");
}
return {
provider,
configured: missing.length === 0,
missing,
};
}
async function sendEmail(input: {
to: string;
subject: string;
html: string;
text: string;
referenceId: string;
}) {
const provider = process.env.EMAIL_PROVIDER ?? "resend";
const from =
process.env.EMAIL_FROM ??
process.env.RESEND_FROM ??
"Vellum <photos@vellum.test>";
const readiness = getEmailReadiness();
if (process.env.NODE_ENV === "production" && !readiness.configured) {
throw new Error(
`Email delivery is not configured: ${readiness.missing.join(", ")}`,
);
}
if (provider === "mailpit" || provider === "smtp") {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST ?? "127.0.0.1",
port: Number(process.env.SMTP_PORT ?? 1025),
secure: process.env.SMTP_SECURE === "true",
});
const result = await transporter.sendMail({
from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
headers: { "X-Entity-Ref-ID": input.referenceId },
});
return { id: result.messageId };
}
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.info(`[email:demo] ${input.subject} -> ${input.to}`);
return { id: `demo-${input.referenceId}` };
}
const resend = new Resend(apiKey);
const { data, error } = await resend.emails.send({
from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
headers: { "X-Entity-Ref-ID": input.referenceId },
});
if (error) throw new Error(error.message);
return { id: data?.id ?? input.referenceId };
}
export async function sendAccountVerificationEmail(message: {
to: string;
name: string;
verificationUrl: string;
}) {
return sendEmail({
to: message.to,
subject: "Verify your Vellum account",
html: chrome(`
<h1 style="margin:0 0 20px;font-size:28px">Verify your email</h1>
<p>Hi ${escapeHtml(message.name)},</p>
<p>Confirm this address to finish creating your Vellum host account.</p>
<p style="margin:24px 0">
<a href="${escapeHtml(message.verificationUrl)}"
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
Verify email
</a>
</p>
`),
text: `Verify your Vellum email: ${message.verificationUrl}`,
referenceId: `account-verification-${referenceHash(message.verificationUrl)}`,
});
}
export async function sendPasswordResetEmail(message: {
to: string;
name: string;
resetUrl: string;
}) {
return sendEmail({
to: message.to,
subject: "Reset your Vellum password",
html: chrome(`
<h1 style="margin:0 0 20px;font-size:28px">Reset your password</h1>
<p>Hi ${escapeHtml(message.name)},</p>
<p>Use the secure link below to choose a new password. The link expires in one hour.</p>
<p style="margin:24px 0">
<a href="${escapeHtml(message.resetUrl)}"
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
Reset password
</a>
</p>
<p style="font-size:12px;color:#7a6e5e">
If you did not request this, you can ignore this message.
</p>
`),
text: `Reset your Vellum password: ${message.resetUrl}\n\nThis link expires in one hour.`,
referenceId: `password-reset-${referenceHash(message.resetUrl)}`,
});
}
export async function sendStaffInviteEmail(message: {
to: string;
inviterName: string;
inviteUrl: string;
}) {
return sendEmail({
to: message.to,
subject: "You're invited to help with a Vellum event",
html: chrome(`
<h1 style="margin:0 0 20px;font-size:28px">You're invited</h1>
<p>${escapeHtml(message.inviterName)} invited you to help manage photos for an event.</p>
<p style="margin:24px 0">
<a href="${escapeHtml(message.inviteUrl)}"
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
Accept invite
</a>
</p>
`),
text: `${message.inviterName} invited you to help with a Vellum event: ${message.inviteUrl}`,
referenceId: `staff-invite-${referenceHash(message.inviteUrl)}`,
});
}
export async function sendAlbumReadyEmail(message: {
to: string;
eventTitle: string;
galleryUrl: string;
}) {
return sendEmail({
to: message.to,
subject: `The gallery for ${message.eventTitle} is ready`,
html: chrome(`
<h1 style="margin:0 0 20px;font-size:28px">The gallery is ready</h1>
<p>Photos from ${escapeHtml(message.eventTitle)} are now available to view.</p>
<p style="margin:24px 0">
<a href="${escapeHtml(message.galleryUrl)}"
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
View gallery
</a>
</p>
`),
text: `The gallery for ${message.eventTitle} is ready: ${message.galleryUrl}`,
referenceId: `album-ready-${referenceHash(message.galleryUrl)}`,
});
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@album/storage",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.864.0",
"dotenv": "^16.5.0"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
+15
View File
@@ -0,0 +1,15 @@
export {
createPresignedGetUrl,
createPresignedPutUrl,
deletePrefix,
ensureBucket,
getObjectBuffer,
headObject,
putObject,
} from "./s3";
export {
displayObjectKey,
originalObjectKey,
photoObjectPrefix,
thumbObjectKey,
} from "./keys";
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import {
displayObjectKey,
originalObjectKey,
photoObjectPrefix,
thumbObjectKey,
} from "./keys";
describe("object keys", () => {
const eventId = "11111111-1111-4111-8111-111111111111";
const photoId = "22222222-2222-4222-8222-222222222222";
test("keeps originals and variants under the same photo prefix", () => {
const prefix = photoObjectPrefix(eventId, photoId);
expect(originalObjectKey(eventId, photoId).startsWith(prefix)).toBe(true);
expect(displayObjectKey(eventId, photoId)).toBe(`${prefix}display.jpg`);
expect(thumbObjectKey(eventId, photoId)).toBe(`${prefix}thumb.jpg`);
});
});
+15
View File
@@ -0,0 +1,15 @@
export function originalObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/original`;
}
export function displayObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/display.jpg`;
}
export function thumbObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/thumb.jpg`;
}
export function photoObjectPrefix(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/`;
}
+200
View File
@@ -0,0 +1,200 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import {
CreateBucketCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutBucketCorsCommand,
PutObjectCommand,
S3Client,
type HeadObjectCommandOutput,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const PRESIGN_PUT_SECONDS = 10 * 60;
const PRESIGN_GET_SECONDS = 60 * 60;
function requiredEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
}
export function storageConfig() {
return {
endpoint: requiredEnv("S3_ENDPOINT"),
publicEndpoint:
process.env.S3_PUBLIC_ENDPOINT?.trim() || requiredEnv("S3_ENDPOINT"),
region: process.env.S3_REGION?.trim() || "us-east-1",
bucket: requiredEnv("S3_BUCKET"),
accessKey: requiredEnv("S3_ACCESS_KEY"),
secretKey: requiredEnv("S3_SECRET_KEY"),
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false",
};
}
function createClient(endpoint: string) {
const config = storageConfig();
return new S3Client({
region: config.region,
endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey,
},
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
});
}
let signingClient: S3Client | undefined;
let publicClient: S3Client | undefined;
let bucketReady = false;
export function getSigningClient() {
signingClient ??= createClient(storageConfig().endpoint);
return signingClient;
}
export function getPublicClient() {
publicClient ??= createClient(storageConfig().publicEndpoint);
return publicClient;
}
export async function ensureBucket() {
if (bucketReady) return;
const client = getSigningClient();
const { bucket } = storageConfig();
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
await client.send(new CreateBucketCommand({ Bucket: bucket }));
}
try {
await client.send(
new PutBucketCorsCommand({
Bucket: bucket,
CORSConfiguration: {
CORSRules: [
{
AllowedHeaders: ["*"],
AllowedMethods: ["GET", "PUT", "HEAD"],
AllowedOrigins: ["*"],
ExposeHeaders: ["ETag", "Content-Length"],
MaxAgeSeconds: 3600,
},
],
},
}),
);
} catch (error) {
console.warn("Could not set bucket CORS", error);
}
bucketReady = true;
}
export async function createPresignedPutUrl(input: {
key: string;
contentType: string;
}) {
await ensureBucket();
const { bucket } = storageConfig();
return getSignedUrl(
getPublicClient(),
new PutObjectCommand({
Bucket: bucket,
Key: input.key,
ContentType: input.contentType,
}),
{ expiresIn: PRESIGN_PUT_SECONDS },
);
}
export async function createPresignedGetUrl(key: string) {
await ensureBucket();
const { bucket } = storageConfig();
return getSignedUrl(
getPublicClient(),
new GetObjectCommand({
Bucket: bucket,
Key: key,
}),
{ expiresIn: PRESIGN_GET_SECONDS },
);
}
export async function headObject(
key: string,
): Promise<HeadObjectCommandOutput | null> {
await ensureBucket();
const { bucket } = storageConfig();
try {
return await getSigningClient().send(
new HeadObjectCommand({ Bucket: bucket, Key: key }),
);
} catch {
return null;
}
}
export async function getObjectBuffer(key: string) {
await ensureBucket();
const { bucket } = storageConfig();
const result = await getSigningClient().send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
);
if (!result.Body) throw new Error(`Object ${key} has no body`);
return Buffer.from(await result.Body.transformToByteArray());
}
export async function putObject(input: {
key: string;
body: Buffer;
contentType: string;
}) {
await ensureBucket();
const { bucket } = storageConfig();
await getSigningClient().send(
new PutObjectCommand({
Bucket: bucket,
Key: input.key,
Body: input.body,
ContentType: input.contentType,
}),
);
}
export async function deletePrefix(prefix: string) {
await ensureBucket();
const { bucket } = storageConfig();
const listed = await getSigningClient().send(
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix }),
);
const keys = (listed.Contents ?? [])
.map((object) => object.Key)
.filter((key): key is string => Boolean(key));
if (keys.length === 0) return;
await getSigningClient().send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: keys.map((Key) => ({ Key })),
},
}),
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts"]
}