diff --git a/CHANGELOG.md b/CHANGELOG.md index 8623fdf..f604a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Contact list management with CSV/XLSX import and column mapping - Email provider integration (Resend and Amazon SES) with encrypted credential storage - Visual block email editor with drag-and-drop, live preview, and merge tags +- Asset library for centrally managing uploaded images and files, with reuse across campaigns via the editor's "Choose from library" picker - Campaign creation, sending, scheduling, and cancellation - Open tracking (pixel) and click tracking (link wrapping) with per-campaign analytics - Unsubscribe page with one-click opt-out and List-Unsubscribe header support diff --git a/README.md b/README.md index 8e07a6c..273666f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Self-hostable broadcast email tool. Manage contact lists, design emails with a v - **Contact Management** - Import contacts via CSV/XLSX upload, organize into lists, track subscription status - **Visual Email Editor** - Drag-and-drop block editor with live preview, mobile/desktop toggle, and merge tag support +- **Asset Library** - Upload images and files once, browse and reuse them across campaigns from a central library - **Multiple Email Providers** - Send through Resend or Amazon SES with encrypted credential storage - **Campaign Sending** - Queue-based sending via pg-boss, with scheduling, cancellation, and per-provider rate limiting - **Open and Click Tracking** - Tracking pixel for opens, link wrapping for clicks, per-campaign analytics with charts diff --git a/ROADMAP.md b/ROADMAP.md index ad9cd7f..7291aec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,7 +32,7 @@ The features below build out the API surface so it's safe, observable, and compl - [ ] **Double opt-in** [Effort: M]: Per-list toggle that requires new contacts to confirm via emailed link before being marked active. Confirmation page and token-based confirmation flow. - [ ] **Embeddable signup forms** [Effort: M]: Form builder, hosted form pages, and a JS embed snippet. Submissions flow into a list and respect double opt-in if enabled. - [ ] **Saved templates library** [Effort: S]: Reusable templates with thumbnails. Save any campaign as a template, start new campaigns from any template. -- [ ] **Asset library** [Effort: M]: Central image and file manager. Upload once, browse and drop into any campaign editor. Replaces per-campaign image uploads. +- [x] **Asset library** [Effort: M]: Central image and file manager. Upload once, browse and drop into any campaign editor. Replaces per-campaign image uploads. - [ ] **A/B testing** [Effort: M]: Subject line and content variants on a campaign. Send variants to a test sample, pick the winner by open or click rate, and send the winner to the rest. - [ ] **Reply-to and reply forwarding** [Effort: S]: Configurable reply-to address per campaign with optional forwarding to a real mailbox. Avoids no-reply senders without building a full inbox. - [ ] **Transactional API and templates** [Effort: M]: Endpoint for high-volume one-off sends like password resets and receipts, using stored templates with variable substitution. Bypasses the campaign and list machinery and reports separately. diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx new file mode 100644 index 0000000..337cdea --- /dev/null +++ b/app/(dashboard)/assets/page.tsx @@ -0,0 +1,303 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { format } from 'date-fns' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useToast } from '@/components/ui/use-toast' + +type Kind = 'all' | 'image' | 'file' + +interface AssetRow { + id: string + fileId: string + name: string + originalName: string + mimeType: string + size: number + kind: 'image' | 'file' + url: string + createdAt: string +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +export default function AssetsPage() { + const { toast } = useToast() + const fileRef = useRef(null) + const [kind, setKind] = useState('all') + const [search, setSearch] = useState('') + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(true) + const [uploading, setUploading] = useState(false) + const [renameTarget, setRenameTarget] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [deleteTarget, setDeleteTarget] = useState(null) + + const fetchAssets = useCallback(async () => { + setLoading(true) + try { + const params = new URLSearchParams({ limit: '100' }) + if (kind !== 'all') params.set('kind', kind) + if (search.trim()) params.set('search', search.trim()) + const res = await fetch(`/api/internal/assets?${params.toString()}`) + if (!res.ok) throw new Error('Failed to load assets') + const json = await res.json() + setAssets(json.data) + } catch { + toast({ title: 'Error', description: 'Could not load assets.', variant: 'destructive' }) + } finally { + setLoading(false) + } + }, [kind, search, toast]) + + useEffect(() => { + const t = setTimeout(fetchAssets, 200) + return () => clearTimeout(t) + }, [fetchAssets]) + + async function uploadFiles(files: FileList | File[]) { + setUploading(true) + let success = 0 + let failed = 0 + for (const file of Array.from(files)) { + const isImage = file.type.startsWith('image/') + // Images keep going through /api/internal/images for the existing flow. + // Generic files use /api/internal/assets POST. + const endpoint = isImage ? '/api/internal/images' : '/api/internal/assets' + try { + const fd = new FormData() + fd.append('file', file) + const res = await fetch(endpoint, { method: 'POST', body: fd }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || 'Upload failed') + } + success++ + } catch { + failed++ + } + } + setUploading(false) + if (success) toast({ title: 'Upload complete', description: `${success} file(s) added.` }) + if (failed) toast({ title: 'Some uploads failed', description: `${failed} file(s) could not be uploaded.`, variant: 'destructive' }) + await fetchAssets() + } + + function handleFileChange(e: React.ChangeEvent) { + if (e.target.files && e.target.files.length) uploadFiles(e.target.files) + e.target.value = '' + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault() + if (e.dataTransfer.files && e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) + } + + async function handleRename() { + if (!renameTarget || !renameValue.trim()) return + try { + const res = await fetch(`/api/internal/assets/${renameTarget.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: renameValue.trim() }), + }) + if (!res.ok) throw new Error('Rename failed') + toast({ title: 'Renamed' }) + setRenameTarget(null) + await fetchAssets() + } catch { + toast({ title: 'Error', description: 'Could not rename asset.', variant: 'destructive' }) + } + } + + async function handleDelete() { + if (!deleteTarget) return + try { + const res = await fetch(`/api/internal/assets/${deleteTarget.id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Delete failed') + toast({ title: 'Deleted' }) + setDeleteTarget(null) + await fetchAssets() + } catch { + toast({ title: 'Error', description: 'Could not delete asset.', variant: 'destructive' }) + } + } + + async function copyUrl(url: string) { + try { + await navigator.clipboard.writeText(url) + toast({ title: 'URL copied' }) + } catch { + toast({ title: 'Error', description: 'Could not copy URL.', variant: 'destructive' }) + } + } + + return ( +
+
+
+

Assets

+

+ Upload images and files once, reuse them across campaigns. +

+
+ + +
+ +
+ setKind(v as Kind)}> + + All + Images + Files + + + setSearch(e.target.value)} + className="max-w-xs" + /> +
+ +
e.preventDefault()} + onDrop={handleDrop} + className="border-2 border-dashed border-border rounded-lg p-6 text-center text-sm text-muted-foreground hover:border-primary/40 transition-colors" + > + Drag and drop files here, or click Upload above. Images max 5MB. Files max 25MB. +
+ + {loading ? ( +

Loading...

+ ) : assets.length === 0 ? ( +
+

No assets yet. Upload your first file above.

+
+ ) : ( +
+ {assets.map((asset) => ( +
+ {asset.kind === 'image' ? ( +
+ {asset.name} +
+ ) : ( +
+ + {(asset.mimeType.split('/')[1] || 'file').slice(0, 4)} + + + {formatBytes(asset.size)} + +
+ )} +
+
+

+ {asset.name} +

+

+ {formatBytes(asset.size)} | {format(new Date(asset.createdAt), 'MMM d, yyyy')} +

+
+
+ + + +
+
+
+ ))} +
+ )} + + !open && setRenameTarget(null)}> + + + Rename asset + +
+ + setRenameValue(e.target.value)} + autoFocus + /> +
+ + + + +
+
+ + !open && setDeleteTarget(null)}> + + + Delete asset? + +

+ {deleteTarget?.name} will be permanently removed. Emails that reference this file + will no longer load it. +

+ + + + +
+
+
+ ) +} diff --git a/app/api/internal/assets/[id]/route.ts b/app/api/internal/assets/[id]/route.ts new file mode 100644 index 0000000..c55e7c8 --- /dev/null +++ b/app/api/internal/assets/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { assets } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { deleteFile } from '@/lib/storage' +import { renameAssetSchema } from '@/lib/validations/assets' +import { auditFromSession, logAudit } from '@/lib/audit' + +export const runtime = 'nodejs' + +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = renameAssetSchema.safeParse(body) + if (!parsed.success) { + const message = parsed.error.errors.map((e) => e.message).join(', ') + return NextResponse.json({ error: message }, { status: 400 }) + } + + const [updated] = await db + .update(assets) + .set({ name: parsed.data.name, updatedAt: new Date() }) + .where(eq(assets.id, params.id)) + .returning() + + if (!updated) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + await logAudit( + await auditFromSession(req), + 'asset.rename', + { type: 'asset', id: updated.id }, + { name: updated.name }, + ) + + return NextResponse.json(updated) +} + +export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { + const [asset] = await db.select().from(assets).where(eq(assets.id, params.id)) + if (!asset) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + try { + await deleteFile(asset.s3Key) + } catch (err) { + console.error('asset delete: s3 removal failed', { id: asset.id, err }) + } + + await db.delete(assets).where(eq(assets.id, params.id)) + + await logAudit( + await auditFromSession(req), + 'asset.delete', + { type: 'asset', id: asset.id }, + { name: asset.name, kind: asset.kind }, + ) + + return NextResponse.json({ success: true }) +} diff --git a/app/api/internal/assets/route.ts b/app/api/internal/assets/route.ts new file mode 100644 index 0000000..5f205cf --- /dev/null +++ b/app/api/internal/assets/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { assets, type Asset } from '@/lib/db/schema' +import { uploadFile } from '@/lib/storage' +import { and, desc, eq, ilike, sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { auditFromSession, logAudit } from '@/lib/audit' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'] +const FILE_TYPES = [ + 'application/pdf', + 'application/zip', + 'application/x-zip-compressed', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + 'text/csv', +] +const MAX_FILE_SIZE = 25 * 1024 * 1024 // 25MB + +function publicUrl(asset: Pick) { + const appUrl = process.env.APP_URL || 'http://localhost:3000' + return asset.kind === 'image' ? `${appUrl}/img/${asset.fileId}` : `${appUrl}/f/${asset.fileId}` +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url) + const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10)) + const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10))) + const kind = url.searchParams.get('kind') + const search = url.searchParams.get('search')?.trim() || '' + + const filters = [] + if (kind === 'image' || kind === 'file') filters.push(eq(assets.kind, kind)) + if (search) filters.push(ilike(assets.name, `%${search}%`)) + const where = filters.length ? and(...filters) : undefined + + const [{ count }] = await db + .select({ count: sql`cast(count(*) as int)` }) + .from(assets) + .where(where) + + const rows = await db + .select() + .from(assets) + .where(where) + .orderBy(desc(assets.createdAt)) + .limit(limit) + .offset((page - 1) * limit) + + const data = rows.map((row) => ({ ...row, url: publicUrl(row) })) + + return NextResponse.json({ data, meta: { page, limit, total: count } }) +} + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData() + const file = formData.get('file') as File | null + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + const isImage = IMAGE_TYPES.includes(file.type) + const isFile = FILE_TYPES.includes(file.type) + if (!isImage && !isFile) { + return NextResponse.json( + { error: 'File type not allowed.' }, + { status: 400 } + ) + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: 'File too large. Maximum size is 25MB.' }, + { status: 400 } + ) + } + + const ext = file.name.split('.').pop() || 'bin' + const fileId = `${nanoid(12)}.${ext}` + const folder = isImage ? 'images' : 'files' + const key = `${folder}/${fileId}` + + const buffer = Buffer.from(await file.arrayBuffer()) + await uploadFile(key, buffer, file.type) + + const [created] = await db + .insert(assets) + .values({ + fileId, + name: file.name, + originalName: file.name, + mimeType: file.type, + size: file.size, + s3Key: key, + kind: isImage ? 'image' : 'file', + }) + .returning() + + await logAudit( + await auditFromSession(req), + 'asset.create', + { type: 'asset', id: created.id }, + { name: created.name, kind: created.kind, size: created.size }, + ) + + return NextResponse.json({ ...created, url: publicUrl(created) }, { status: 201 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Upload failed' + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/app/api/internal/images/route.ts b/app/api/internal/images/route.ts index 6e2dcca..390aab4 100644 --- a/app/api/internal/images/route.ts +++ b/app/api/internal/images/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { uploadFile } from '@/lib/storage' +import { db } from '@/lib/db' +import { assets } from '@/lib/db/schema' import { nanoid } from 'nanoid' // Allow file uploads up to 10MB @@ -39,6 +41,16 @@ export async function POST(req: NextRequest) { const buffer = Buffer.from(await file.arrayBuffer()) await uploadFile(key, buffer, file.type) + await db.insert(assets).values({ + fileId, + name: file.name, + originalName: file.name, + mimeType: file.type, + size: file.size, + s3Key: key, + kind: 'image', + }) + // Public URL served through our proxy route (no expiry, clean URL) const appUrl = process.env.APP_URL || 'http://localhost:3000' const url = `${appUrl}/img/${fileId}` diff --git a/app/f/[id]/route.ts b/app/f/[id]/route.ts new file mode 100644 index 0000000..bb0d037 --- /dev/null +++ b/app/f/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { assets } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { getFile } from '@/lib/storage' + +export async function GET( + _req: NextRequest, + { params }: { params: { id: string } } +) { + if (!/^[\w-]+\.\w+$/.test(params.id)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const [asset] = await db + .select() + .from(assets) + .where(eq(assets.fileId, params.id)) + + if (!asset) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + try { + const file = await getFile(asset.s3Key) + if (!file.body) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const bytes = await file.body.transformToByteArray() + const downloadName = asset.originalName.replace(/[^\w. -]/g, '_') + + return new NextResponse(Buffer.from(bytes), { + headers: { + 'Content-Type': asset.mimeType || file.contentType, + 'Content-Disposition': `inline; filename="${downloadName}"`, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Access-Control-Allow-Origin': '*', + }, + }) + } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } +} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 4360718..b4f8725 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -36,6 +36,17 @@ const mainNavItems: NavItem[] = [ ), }, + { + label: 'Assets', + href: '/assets', + icon: ( + + + + + + ), + }, { label: 'Campaigns', href: '/campaigns', diff --git a/components/editor/AssetPicker.tsx b/components/editor/AssetPicker.tsx new file mode 100644 index 0000000..7165bff --- /dev/null +++ b/components/editor/AssetPicker.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' + +export interface PickerAsset { + id: string + fileId: string + name: string + url: string + kind: 'image' | 'file' + mimeType: string + size: number + createdAt: string +} + +interface AssetPickerProps { + open: boolean + onOpenChange: (open: boolean) => void + kind?: 'image' | 'file' + onSelect: (asset: PickerAsset) => void +} + +export function AssetPicker({ open, onOpenChange, kind = 'image', onSelect }: AssetPickerProps) { + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + + useEffect(() => { + if (!open) return + let cancelled = false + setLoading(true) + const params = new URLSearchParams({ limit: '60', kind }) + if (search.trim()) params.set('search', search.trim()) + const t = setTimeout(async () => { + try { + const res = await fetch(`/api/internal/assets?${params.toString()}`) + if (!res.ok) throw new Error('Failed to load assets') + const json = await res.json() + if (!cancelled) setAssets(json.data) + } finally { + if (!cancelled) setLoading(false) + } + }, 200) + return () => { + cancelled = true + clearTimeout(t) + } + }, [open, kind, search]) + + return ( + + + + Choose from library + + + setSearch(e.target.value)} + /> + +
+ {loading ? ( +

Loading...

+ ) : assets.length === 0 ? ( +

+ No assets found. Upload from the Assets page. +

+ ) : ( +
+ {assets.map((asset) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/components/editor/blocks/ImageBlock.tsx b/components/editor/blocks/ImageBlock.tsx index 863c18e..c4c33d0 100644 --- a/components/editor/blocks/ImageBlock.tsx +++ b/components/editor/blocks/ImageBlock.tsx @@ -4,6 +4,7 @@ import { useState, useRef } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' +import { AssetPicker } from '@/components/editor/AssetPicker' interface BlockEditorProps { props: Record @@ -16,6 +17,7 @@ export function ImageBlock({ props, onChange }: BlockEditorProps) { const width = (props.width as string) ?? '100%' const [uploading, setUploading] = useState(false) const [uploadError, setUploadError] = useState('') + const [pickerOpen, setPickerOpen] = useState(false) const fileRef = useRef(null) async function handleFileUpload(file: File) { @@ -104,6 +106,22 @@ export function ImageBlock({ props, onChange }: BlockEditorProps) { )} + + + onChange({ ...props, src: asset.url })} + /> +
diff --git a/drizzle/migrations/0002_amazing_khan.sql b/drizzle/migrations/0002_amazing_khan.sql new file mode 100644 index 0000000..8d6d553 --- /dev/null +++ b/drizzle/migrations/0002_amazing_khan.sql @@ -0,0 +1,37 @@ +CREATE TABLE "assets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "file_id" text NOT NULL, + "name" text NOT NULL, + "original_name" text NOT NULL, + "mime_type" text NOT NULL, + "size" integer NOT NULL, + "s3_key" text NOT NULL, + "kind" text 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, + CONSTRAINT "assets_file_id_unique" UNIQUE("file_id") +); +--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "actor_type" text NOT NULL, + "actor_id" text, + "actor_label" text, + "action" text NOT NULL, + "resource_type" text, + "resource_id" text, + "metadata" jsonb DEFAULT '{}'::jsonb, + "ip_address" text, + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "rate_limit_per_minute" integer DEFAULT 60 NOT NULL;--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "rate_limit_tokens" double precision DEFAULT 60 NOT NULL;--> statement-breakpoint +ALTER TABLE "api_keys" ADD COLUMN "rate_limit_updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +CREATE INDEX "assets_kind_idx" ON "assets" USING btree ("kind");--> statement-breakpoint +CREATE INDEX "assets_created_idx" ON "assets" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_resource_idx" ON "audit_logs" USING btree ("resource_type","resource_id"); \ No newline at end of file diff --git a/drizzle/migrations/meta/0002_snapshot.json b/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..52a2182 --- /dev/null +++ b/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,948 @@ +{ + "id": "5f04f962-9d79-435a-96f3-1e64c8561b4f", + "prevId": "378e93f9-e1c7-4ee8-bdaf-4e645c039b90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rate_limit_per_minute": { + "name": "rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "rate_limit_tokens": { + "name": "rate_limit_tokens", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "rate_limit_updated_at": { + "name": "rate_limit_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "assets_kind_idx": { + "name": "assets_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_created_idx": { + "name": "assets_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "assets_file_id_unique": { + "name": "assets_file_id_unique", + "nullsNotDistinct": false, + "columns": [ + "file_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_resource_idx": { + "name": "audit_logs_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_events": { + "name": "campaign_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "campaign_send_id": { + "name": "campaign_send_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_events_campaign_send_id_campaign_sends_id_fk": { + "name": "campaign_events_campaign_send_id_campaign_sends_id_fk", + "tableFrom": "campaign_events", + "tableTo": "campaign_sends", + "columnsFrom": [ + "campaign_send_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_events_campaign_id_campaigns_id_fk": { + "name": "campaign_events_campaign_id_campaigns_id_fk", + "tableFrom": "campaign_events", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_sends": { + "name": "campaign_sends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "campaign_id": { + "name": "campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_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": {}, + "foreignKeys": { + "campaign_sends_campaign_id_campaigns_id_fk": { + "name": "campaign_sends_campaign_id_campaigns_id_fk", + "tableFrom": "campaign_sends", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_sends_contact_id_contacts_id_fk": { + "name": "campaign_sends_contact_id_contacts_id_fk", + "tableFrom": "campaign_sends", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "campaign_sends_campaign_id_contact_id_unique": { + "name": "campaign_sends_campaign_id_contact_id_unique", + "nullsNotDistinct": false, + "columns": [ + "campaign_id", + "contact_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "template_json": { + "name": "template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "template_html": { + "name": "template_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_recipients": { + "name": "total_recipients", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cancel_requested": { + "name": "cancel_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "campaigns_list_id_lists_id_fk": { + "name": "campaigns_list_id_lists_id_fk", + "tableFrom": "campaigns", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "campaigns_provider_id_email_providers_id_fk": { + "name": "campaigns_provider_id_email_providers_id_fk", + "tableFrom": "campaigns", + "tableTo": "email_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "unsubscribe_token": { + "name": "unsubscribe_token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "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": { + "contacts_list_id_lists_id_fk": { + "name": "contacts_list_id_lists_id_fk", + "tableFrom": "contacts", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contacts_unsubscribe_token_unique": { + "name": "contacts_unsubscribe_token_unique", + "nullsNotDistinct": false, + "columns": [ + "unsubscribe_token" + ] + }, + "contacts_list_id_email_unique": { + "name": "contacts_list_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "list_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_providers": { + "name": "email_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_encrypted": { + "name": "config_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_per_second": { + "name": "rate_limit_per_second", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lists": { + "name": "lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suppressions": { + "name": "suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "suppressions_email_unique": { + "name": "suppressions_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 7c2334f..80cd755 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777752836747, "tag": "0001_woozy_silvermane", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1777840796783, + "tag": "0002_amazing_khan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 10c7786..82537b5 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -115,6 +115,24 @@ export const auditLogs = pgTable('audit_logs', { resourceIdx: index('audit_logs_resource_idx').on(t.resourceType, t.resourceId), })) +export const assets = pgTable('assets', { + id: uuid('id').primaryKey().defaultRandom(), + fileId: text('file_id').notNull().unique(), + name: text('name').notNull(), + originalName: text('original_name').notNull(), + mimeType: text('mime_type').notNull(), + size: integer('size').notNull(), + s3Key: text('s3_key').notNull(), + kind: text('kind').notNull(), // image | file + width: integer('width'), + height: integer('height'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (t) => ({ + kindIdx: index('assets_kind_idx').on(t.kind), + createdIdx: index('assets_created_idx').on(t.createdAt), +})) + // Block type used in templateJson export type BlockType = 'heading' | 'text' | 'image' | 'button' | 'divider' | 'spacer' @@ -134,3 +152,4 @@ export type CampaignEvent = typeof campaignEvents.$inferSelect export type ApiKey = typeof apiKeys.$inferSelect export type Suppression = typeof suppressions.$inferSelect export type AuditLog = typeof auditLogs.$inferSelect +export type Asset = typeof assets.$inferSelect diff --git a/lib/validations/assets.ts b/lib/validations/assets.ts new file mode 100644 index 0000000..4666935 --- /dev/null +++ b/lib/validations/assets.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const renameAssetSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), +}) + +export type RenameAssetInput = z.infer diff --git a/middleware.ts b/middleware.ts index 39274d4..2fe8ae9 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,6 +2,6 @@ export { default } from 'next-auth/middleware' export const config = { matcher: [ - '/((?!api/auth|api/v1|api/webhooks|img|login|unsubscribe|t|r|_next/static|_next/image|favicon.ico).*)', + '/((?!api/auth|api/v1|api/webhooks|img|f|login|unsubscribe|t|r|_next/static|_next/image|favicon.ico).*)', ], }