feat: file/screenshot attachments on support tickets (v0.49.1)
Support form now accepts up to 5 attachments (images, PDF, text, JSON, zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New support_ticket_attachments table; attachment links get folded into the Gitea issue body and shown as thumbnails in both the user's ticket history and the admin support view. New presign endpoint (/api/v1/support/attachments/presign, rate-limited 20/hr) scoped to the uploading user rather than a ticket id, since the ticket doesn't exist yet at upload time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.49.1 — 2026-07-18 13:10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Support form now accepts attachments — screenshots or files (images, PDF, text, JSON, zip; up to 5 files, 10MB each). They're linked in the confirmation email's Gitea issue and shown as thumbnails on your ticket history and in the admin support view.
|
||||||
|
|
||||||
## 0.49.0 — 2026-07-18 12:30
|
## 0.49.0 — 2026-07-18 12:30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { auth } from "@/lib/auth/server";
|
|||||||
import { db, supportTickets, eq, desc } from "@epicure/db";
|
import { db, supportTickets, eq, desc } from "@epicure/db";
|
||||||
import { SupportManager } from "@/components/support/support-manager";
|
import { SupportManager } from "@/components/support/support-manager";
|
||||||
import { getMessages } from "@/lib/i18n/server";
|
import { getMessages } from "@/lib/i18n/server";
|
||||||
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -12,11 +13,11 @@ export default async function SupportPage() {
|
|||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const m = getMessages((session.user as { locale?: string }).locale);
|
const m = getMessages((session.user as { locale?: string }).locale);
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db.query.supportTickets.findMany({
|
||||||
.select()
|
where: eq(supportTickets.userId, session.user.id),
|
||||||
.from(supportTickets)
|
orderBy: desc(supportTickets.createdAt),
|
||||||
.where(eq(supportTickets.userId, session.user.id))
|
with: { attachments: true },
|
||||||
.orderBy(desc(supportTickets.createdAt));
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-3xl">
|
<div className="space-y-8 max-w-3xl">
|
||||||
@@ -33,6 +34,11 @@ export default async function SupportPage() {
|
|||||||
status: r.status,
|
status: r.status,
|
||||||
giteaIssueUrl: r.giteaIssueUrl,
|
giteaIssueUrl: r.giteaIssueUrl,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
attachments: r.attachments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
contentType: a.contentType,
|
||||||
|
url: getPublicUrl(a.storageKey),
|
||||||
|
})),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { db, users, supportTickets, eq, desc } from "@epicure/db";
|
import { db, supportTickets, desc } from "@epicure/db";
|
||||||
import { AdminSupportManager } from "@/components/admin/admin-support-manager";
|
import { AdminSupportManager } from "@/components/admin/admin-support-manager";
|
||||||
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
export default async function AdminSupportPage() {
|
export default async function AdminSupportPage() {
|
||||||
const rows = await db
|
const rows = await db.query.supportTickets.findMany({
|
||||||
.select({
|
orderBy: desc(supportTickets.createdAt),
|
||||||
id: supportTickets.id,
|
with: {
|
||||||
userEmail: users.email,
|
attachments: true,
|
||||||
username: users.username,
|
user: { columns: { email: true, username: true } },
|
||||||
type: supportTickets.type,
|
},
|
||||||
title: supportTickets.title,
|
});
|
||||||
description: supportTickets.description,
|
|
||||||
status: supportTickets.status,
|
|
||||||
giteaIssueUrl: supportTickets.giteaIssueUrl,
|
|
||||||
giteaError: supportTickets.giteaError,
|
|
||||||
createdAt: supportTickets.createdAt,
|
|
||||||
})
|
|
||||||
.from(supportTickets)
|
|
||||||
.innerJoin(users, eq(supportTickets.userId, users.id))
|
|
||||||
.orderBy(desc(supportTickets.createdAt));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -31,7 +23,23 @@ export default async function AdminSupportPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AdminSupportManager
|
<AdminSupportManager
|
||||||
initialTickets={rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))}
|
initialTickets={rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
userEmail: r.user.email,
|
||||||
|
username: r.user.username,
|
||||||
|
type: r.type,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
status: r.status,
|
||||||
|
giteaIssueUrl: r.giteaIssueUrl,
|
||||||
|
giteaError: r.giteaError,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
attachments: r.attachments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
contentType: a.contentType,
|
||||||
|
url: getPublicUrl(a.storageKey),
|
||||||
|
})),
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { db, users, supportTickets, eq, desc } from "@epicure/db";
|
import { db, supportTickets, desc } from "@epicure/db";
|
||||||
import { requireAdmin } from "@/lib/api-auth";
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { response } = await requireAdmin();
|
const { response } = await requireAdmin();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db.query.supportTickets.findMany({
|
||||||
.select({
|
orderBy: desc(supportTickets.createdAt),
|
||||||
id: supportTickets.id,
|
with: {
|
||||||
userId: supportTickets.userId,
|
attachments: true,
|
||||||
userEmail: users.email,
|
user: { columns: { email: true, username: true } },
|
||||||
username: users.username,
|
},
|
||||||
type: supportTickets.type,
|
});
|
||||||
title: supportTickets.title,
|
|
||||||
description: supportTickets.description,
|
|
||||||
status: supportTickets.status,
|
|
||||||
giteaIssueUrl: supportTickets.giteaIssueUrl,
|
|
||||||
giteaError: supportTickets.giteaError,
|
|
||||||
createdAt: supportTickets.createdAt,
|
|
||||||
updatedAt: supportTickets.updatedAt,
|
|
||||||
})
|
|
||||||
.from(supportTickets)
|
|
||||||
.innerJoin(users, eq(supportTickets.userId, users.id))
|
|
||||||
.orderBy(desc(supportTickets.createdAt));
|
|
||||||
|
|
||||||
return NextResponse.json(rows);
|
return NextResponse.json(
|
||||||
|
rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
userId: r.userId,
|
||||||
|
userEmail: r.user.email,
|
||||||
|
username: r.user.username,
|
||||||
|
type: r.type,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
status: r.status,
|
||||||
|
giteaIssueUrl: r.giteaIssueUrl,
|
||||||
|
giteaError: r.giteaError,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
attachments: r.attachments.map((a) => ({ ...a, url: getPublicUrl(a.storageKey) })),
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { createPresignedUploadPost } from "@/lib/storage";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/avif",
|
||||||
|
"image/gif",
|
||||||
|
"application/pdf",
|
||||||
|
"text/plain",
|
||||||
|
"application/json",
|
||||||
|
"application/zip",
|
||||||
|
] as const;
|
||||||
|
type AllowedType = (typeof ALLOWED_TYPES)[number];
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
contentType: z.string().refine((t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t), {
|
||||||
|
message: "Unsupported file type",
|
||||||
|
}),
|
||||||
|
fileSize: z.number().int().positive().max(MAX_FILE_SIZE, "File exceeds 10MB limit"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const rateLimitResponse = await applyRateLimit(`rl:support-attachment:${session!.user.id}`, 20, 3600);
|
||||||
|
if (rateLimitResponse) return rateLimitResponse;
|
||||||
|
|
||||||
|
const body = (await req.json()) as unknown;
|
||||||
|
const parsed = Schema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contentType, fileSize } = parsed.data;
|
||||||
|
const ext = contentType.split("/")[1] ?? "bin";
|
||||||
|
const key = `support/${session!.user.id}/${crypto.randomUUID()}.${ext}`;
|
||||||
|
const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE);
|
||||||
|
|
||||||
|
return NextResponse.json({ url, fields, key, contentType, fileSize });
|
||||||
|
}
|
||||||
@@ -1,28 +1,44 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, supportTickets, eq, desc } from "@epicure/db";
|
import { db, supportTickets, supportTicketAttachments, eq, desc } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
import { sendEmail, supportTicketReceivedHtml } from "@/lib/email";
|
import { sendEmail, supportTicketReceivedHtml } from "@/lib/email";
|
||||||
import { createGiteaIssue } from "@/lib/gitea";
|
import { createGiteaIssue } from "@/lib/gitea";
|
||||||
|
import { getPublicUrl, isOwnedSupportAttachmentKey } from "@/lib/storage";
|
||||||
|
|
||||||
|
const MAX_ATTACHMENTS = 5;
|
||||||
|
|
||||||
const CreateTicketBody = z.object({
|
const CreateTicketBody = z.object({
|
||||||
type: z.enum(["bug", "suggestion", "question"]),
|
type: z.enum(["bug", "suggestion", "question"]),
|
||||||
title: z.string().trim().min(3).max(200),
|
title: z.string().trim().min(3).max(200),
|
||||||
description: z.string().trim().min(10).max(5000),
|
description: z.string().trim().min(10).max(5000),
|
||||||
|
attachments: z
|
||||||
|
.array(z.object({
|
||||||
|
key: z.string().min(1).max(500),
|
||||||
|
contentType: z.string().min(1).max(100),
|
||||||
|
sizeBytes: z.number().int().positive(),
|
||||||
|
}))
|
||||||
|
.max(MAX_ATTACHMENTS)
|
||||||
|
.default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db.query.supportTickets.findMany({
|
||||||
.select()
|
where: eq(supportTickets.userId, session!.user.id),
|
||||||
.from(supportTickets)
|
orderBy: desc(supportTickets.createdAt),
|
||||||
.where(eq(supportTickets.userId, session!.user.id))
|
with: { attachments: true },
|
||||||
.orderBy(desc(supportTickets.createdAt));
|
});
|
||||||
|
|
||||||
return NextResponse.json(rows);
|
return NextResponse.json(
|
||||||
|
rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
attachments: r.attachments.map((a) => ({ ...a, url: getPublicUrl(a.storageKey) })),
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -38,14 +54,28 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, title, description } = parsed.data;
|
const { type, title, description, attachments } = parsed.data;
|
||||||
|
|
||||||
|
for (const a of attachments) {
|
||||||
|
if (!isOwnedSupportAttachmentKey(a.key, session!.user.id)) {
|
||||||
|
return NextResponse.json({ error: "Invalid attachment" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
const attachmentLinks = attachments.map((a) => getPublicUrl(a.key));
|
||||||
|
const giteaBody = [
|
||||||
|
description,
|
||||||
|
attachmentLinks.length > 0 ? `\n**Attachments:**\n${attachmentLinks.map((u) => `- ${u}`).join("\n")}` : "",
|
||||||
|
`\n---\nReported via Epicure support form by user \`${session!.user.id}\`.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
const { url: giteaIssueUrl, error: giteaError } = await createGiteaIssue({
|
const { url: giteaIssueUrl, error: giteaError } = await createGiteaIssue({
|
||||||
type,
|
type,
|
||||||
title,
|
title,
|
||||||
body: `${description}\n\n---\nReported via Epicure support form by user \`${session!.user.id}\`.`,
|
body: giteaBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(supportTickets).values({
|
await db.insert(supportTickets).values({
|
||||||
@@ -61,6 +91,19 @@ export async function POST(req: NextRequest) {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
await db.insert(supportTicketAttachments).values(
|
||||||
|
attachments.map((a) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
ticketId: id,
|
||||||
|
storageKey: a.key,
|
||||||
|
contentType: a.contentType,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
createdAt: now,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
|
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
|
||||||
const ticketUrl = `${baseUrl}/support`;
|
const ticketUrl = `${baseUrl}/support`;
|
||||||
if (session!.user.email) {
|
if (session!.user.email) {
|
||||||
@@ -87,6 +130,13 @@ export async function POST(req: NextRequest) {
|
|||||||
giteaError,
|
giteaError,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
|
attachments: attachments.map((a) => ({
|
||||||
|
id: "",
|
||||||
|
storageKey: a.key,
|
||||||
|
contentType: a.contentType,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
url: getPublicUrl(a.key),
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink, FileText } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
|
|
||||||
type TicketStatus = "open" | "triaged" | "closed";
|
type TicketStatus = "open" | "triaged" | "closed";
|
||||||
|
|
||||||
|
type Attachment = { id: string; contentType: string; url: string };
|
||||||
|
|
||||||
type Ticket = {
|
type Ticket = {
|
||||||
id: string;
|
id: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
@@ -27,8 +30,13 @@ type Ticket = {
|
|||||||
giteaIssueUrl: string | null;
|
giteaIssueUrl: string | null;
|
||||||
giteaError: string | null;
|
giteaError: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
attachments: Attachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isImage(contentType: string) {
|
||||||
|
return contentType.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(iso: string) {
|
function formatDateTime(iso: string) {
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -113,6 +121,27 @@ export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
||||||
|
{ticket.attachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{ticket.attachments.map((a) => (
|
||||||
|
<Link
|
||||||
|
key={a.id}
|
||||||
|
href={a.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="relative h-12 w-12 rounded-md border overflow-hidden block"
|
||||||
|
>
|
||||||
|
{isImage(a.contentType) ? (
|
||||||
|
<Image src={a.url} unoptimized alt="Attachment" fill className="object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full flex items-center justify-center bg-muted/40">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 text-xs flex-wrap pt-1">
|
<div className="flex items-center gap-2 text-xs flex-wrap pt-1">
|
||||||
<Badge variant="outline" className="text-xs">{ticket.type}</Badge>
|
<Badge variant="outline" className="text-xs">{ticket.type}</Badge>
|
||||||
<Badge variant={statusVariant[ticket.status]} className="text-xs">{ticket.status}</Badge>
|
<Badge variant={statusVariant[ticket.status]} className="text-xs">{ticket.status}</Badge>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink, Paperclip, X, FileText } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -17,10 +18,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { uploadToPresignedPost } from "@/lib/upload-client";
|
||||||
|
|
||||||
type TicketType = "bug" | "suggestion" | "question";
|
type TicketType = "bug" | "suggestion" | "question";
|
||||||
type TicketStatus = "open" | "triaged" | "closed";
|
type TicketStatus = "open" | "triaged" | "closed";
|
||||||
|
|
||||||
|
type Attachment = { id: string; contentType: string; url: string };
|
||||||
|
|
||||||
type Ticket = {
|
type Ticket = {
|
||||||
id: string;
|
id: string;
|
||||||
type: TicketType;
|
type: TicketType;
|
||||||
@@ -29,8 +33,21 @@ type Ticket = {
|
|||||||
status: TicketStatus;
|
status: TicketStatus;
|
||||||
giteaIssueUrl: string | null;
|
giteaIssueUrl: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
attachments: Attachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingAttachment = {
|
||||||
|
key: string;
|
||||||
|
contentType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
preview: string | null;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ATTACHMENTS = 5;
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
const ACCEPT = "image/jpeg,image/png,image/webp,image/avif,image/gif,application/pdf,text/plain,application/json,application/zip";
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(iso: string) {
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -39,6 +56,10 @@ function formatDate(iso: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isImage(contentType: string) {
|
||||||
|
return contentType.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
export function SupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
export function SupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
||||||
const t = useTranslations("support");
|
const t = useTranslations("support");
|
||||||
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
||||||
@@ -46,6 +67,54 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||||
|
const [uploadingCount, setUploadingCount] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function handleFiles(files: FileList) {
|
||||||
|
const room = MAX_ATTACHMENTS - pendingAttachments.length;
|
||||||
|
const toUpload = Array.from(files).slice(0, Math.max(0, room));
|
||||||
|
if (files.length > toUpload.length) {
|
||||||
|
toast.error(t("attachmentLimitReached", { count: MAX_ATTACHMENTS }));
|
||||||
|
}
|
||||||
|
setUploadingCount((c) => c + toUpload.length);
|
||||||
|
for (const file of toUpload) {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
toast.error(t("attachmentTooLarge", { name: file.name }));
|
||||||
|
setUploadingCount((c) => c - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/support/attachments/presign", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ contentType: file.type, fileSize: file.size }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const { url, fields, key } = (await res.json()) as { url: string; fields: Record<string, string>; key: string };
|
||||||
|
const uploaded = await uploadToPresignedPost(url, fields, file);
|
||||||
|
if (!uploaded) throw new Error();
|
||||||
|
setPendingAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key,
|
||||||
|
contentType: file.type,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
preview: isImage(file.type) ? URL.createObjectURL(file) : null,
|
||||||
|
name: file.name,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("attachmentUploadFailed", { name: file.name }));
|
||||||
|
} finally {
|
||||||
|
setUploadingCount((c) => c - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePending(key: string) {
|
||||||
|
setPendingAttachments((prev) => prev.filter((a) => a.key !== key));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -55,7 +124,16 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
|||||||
const res = await fetch("/api/v1/support", {
|
const res = await fetch("/api/v1/support", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ type, title: title.trim(), description: description.trim() }),
|
body: JSON.stringify({
|
||||||
|
type,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
attachments: pendingAttachments.map((a) => ({
|
||||||
|
key: a.key,
|
||||||
|
contentType: a.contentType,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = (await res.json()) as { error?: string };
|
const data = (await res.json()) as { error?: string };
|
||||||
@@ -66,6 +144,7 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
|||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setType("bug");
|
setType("bug");
|
||||||
|
setPendingAttachments([]);
|
||||||
toast.success(t("submitSuccess"));
|
toast.success(t("submitSuccess"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : t("submitFailed"));
|
toast.error(err instanceof Error ? err.message : t("submitFailed"));
|
||||||
@@ -122,7 +201,60 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={submitting || title.trim().length < 3 || description.trim().length < 10}>
|
<div className="space-y-2">
|
||||||
|
<Label>{t("attachmentsLabel")}</Label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{pendingAttachments.map((a) => (
|
||||||
|
<div key={a.key} className="relative group h-16 w-16">
|
||||||
|
{a.preview ? (
|
||||||
|
<Image
|
||||||
|
src={a.preview}
|
||||||
|
unoptimized
|
||||||
|
alt={a.name}
|
||||||
|
fill
|
||||||
|
className="rounded-lg object-cover border"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full rounded-lg border flex flex-col items-center justify-center gap-0.5 bg-muted/40 px-1">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-[9px] text-muted-foreground truncate w-full text-center">{a.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePending(a.key)}
|
||||||
|
className="absolute -top-1.5 -right-1.5 rounded-full bg-destructive text-destructive-foreground p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title={t("removeAttachment")}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pendingAttachments.length < MAX_ATTACHMENTS && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={uploadingCount > 0}
|
||||||
|
className="h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-1 text-muted-foreground transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
<span className="text-[10px]">{uploadingCount > 0 ? t("attachmentUploading") : t("attachmentAdd")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("attachmentsHint", { count: MAX_ATTACHMENTS })}</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPT}
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting || uploadingCount > 0 || title.trim().length < 3 || description.trim().length < 10}>
|
||||||
{submitting ? t("submitting") : t("submitButton")}
|
{submitting ? t("submitting") : t("submitButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -142,6 +274,27 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
||||||
|
{ticket.attachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{ticket.attachments.map((a) => (
|
||||||
|
<Link
|
||||||
|
key={a.id}
|
||||||
|
href={a.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="relative h-12 w-12 rounded-md border overflow-hidden block"
|
||||||
|
>
|
||||||
|
{isImage(a.contentType) ? (
|
||||||
|
<Image src={a.url} unoptimized alt="Attachment" fill className="object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full flex items-center justify-center bg-muted/40">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-1">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-1">
|
||||||
<Badge variant="outline" className="text-xs">{t(`type.${ticket.type}`)}</Badge>
|
<Badge variant="outline" className="text-xs">{t(`type.${ticket.type}`)}</Badge>
|
||||||
<span>{formatDate(ticket.createdAt)}</span>
|
<span>{formatDate(ticket.createdAt)}</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.49.0";
|
export const APP_VERSION = "0.49.1";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.49.1",
|
||||||
|
date: "2026-07-18 13:10",
|
||||||
|
added: [
|
||||||
|
"Support form now accepts attachments — screenshots or files (images, PDF, text, JSON, zip; up to 5 files, 10MB each). They're linked in the confirmation email's Gitea issue and shown as thumbnails on your ticket history and in the admin support view.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.49.0",
|
version: "0.49.0",
|
||||||
date: "2026-07-18 12:30",
|
date: "2026-07-18 12:30",
|
||||||
|
|||||||
+17
-2
@@ -585,17 +585,32 @@ export function generateOpenApiSpec(): object {
|
|||||||
// --- Support: bug reports, suggestions, and questions submitted in-app ---
|
// --- Support: bug reports, suggestions, and questions submitted in-app ---
|
||||||
const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]);
|
const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]);
|
||||||
const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]);
|
const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]);
|
||||||
|
const SupportAttachmentRef = registry.register("SupportAttachment", z.object({
|
||||||
|
id: z.string(), contentType: z.string(), url: z.string(),
|
||||||
|
}));
|
||||||
const SupportTicketRef = registry.register("SupportTicket", z.object({
|
const SupportTicketRef = registry.register("SupportTicket", z.object({
|
||||||
id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
|
id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
|
||||||
status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaError: z.string().nullable(),
|
status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaError: z.string().nullable(),
|
||||||
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
attachments: z.array(SupportAttachmentRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||||
}));
|
}));
|
||||||
const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
|
const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
|
||||||
type: SupportTicketTypeEnum, title: z.string().min(3).max(200), description: z.string().min(10).max(5000),
|
type: SupportTicketTypeEnum, title: z.string().min(3).max(200), description: z.string().min(10).max(5000),
|
||||||
|
attachments: z.array(z.object({
|
||||||
|
key: z.string().describe("Storage key returned by POST /api/v1/support/attachments/presign"),
|
||||||
|
contentType: z.string(), sizeBytes: z.number().int().positive(),
|
||||||
|
})).max(5).default([]),
|
||||||
|
}));
|
||||||
|
const PresignSupportAttachmentRef = registry.register("PresignSupportAttachment", z.object({
|
||||||
|
contentType: z.string(), fileSize: z.number().int().positive().max(10 * 1024 * 1024),
|
||||||
|
}));
|
||||||
|
const PresignedPostRef = registry.register("PresignedSupportPost", z.object({
|
||||||
|
url: z.string(), fields: z.record(z.string(), z.string()), key: z.string(),
|
||||||
|
contentType: z.string(), fileSize: z.number().int(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/support", summary: "List your support tickets", security, responses: { 200: { description: "Tickets, newest first", content: { "application/json": { schema: z.array(SupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/support", summary: "List your support tickets", security, responses: { 200: { description: "Tickets, newest first", content: { "application/json": { schema: z.array(SupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "post", path: "/api/v1/support", summary: "Submit a bug report, suggestion, or question", description: "Best-effort opens a matching issue on the configured Gitea repo (see giteaIssueUrl/giteaError) — this never blocks ticket creation. Sends a confirmation email.", security, request: { body: { content: { "application/json": { schema: CreateSupportTicketRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "post", path: "/api/v1/support", summary: "Submit a bug report, suggestion, or question", description: "Best-effort opens a matching issue on the configured Gitea repo (see giteaIssueUrl/giteaError) — this never blocks ticket creation. Sends a confirmation email. Attach files by presigning each one first via POST /api/v1/support/attachments/presign, uploading to the returned URL, then passing the keys here.", security, request: { body: { content: { "application/json": { schema: CreateSupportTicketRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
registry.registerPath({ method: "post", path: "/api/v1/support/attachments/presign", summary: "Get a presigned upload URL for a support ticket attachment", description: "Rate-limited: 20 req/hour. Accepts images, PDF, plain text, JSON, or zip, up to 10MB. Upload the file as multipart form data to the returned url+fields, then include the returned key when submitting the ticket.", security, request: { body: { content: { "application/json": { schema: PresignSupportAttachmentRef } }, required: true } }, responses: { 200: { description: "Presigned upload", content: { "application/json": { schema: PresignedPostRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|
||||||
// --- Conversations: 1:1 direct messages ---
|
// --- Conversations: 1:1 direct messages ---
|
||||||
const ConversationSummaryRef = registry.register("ConversationSummary", z.object({
|
const ConversationSummaryRef = registry.register("ConversationSummary", z.object({
|
||||||
|
|||||||
@@ -87,3 +87,9 @@ export function isOwnedReviewPhotoKey(key: string, recipeId: string, userId: str
|
|||||||
export function isOwnedAvatarKey(key: string, userId: string): boolean {
|
export function isOwnedAvatarKey(key: string, userId: string): boolean {
|
||||||
return key.startsWith(`user-avatars/${userId}/`);
|
return key.startsWith(`user-avatars/${userId}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same rationale as isOwnedRecipePhotoKey — a support ticket doesn't exist yet
|
||||||
|
* at upload time, so ownership is scoped to the uploading user, not a ticket id. */
|
||||||
|
export function isOwnedSupportAttachmentKey(key: string, userId: string): boolean {
|
||||||
|
return key.startsWith(`support/${userId}/`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -462,6 +462,14 @@
|
|||||||
"titlePlaceholder": "Short summary of the issue or idea",
|
"titlePlaceholder": "Short summary of the issue or idea",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"descriptionPlaceholder": "What happened, what you expected, and steps to reproduce (if a bug) — or details on your suggestion/question.",
|
"descriptionPlaceholder": "What happened, what you expected, and steps to reproduce (if a bug) — or details on your suggestion/question.",
|
||||||
|
"attachmentsLabel": "Attachments",
|
||||||
|
"attachmentsHint": "Screenshots or files, up to {count}. Images, PDF, text, JSON, or zip — 10MB max each.",
|
||||||
|
"attachmentAdd": "Add file",
|
||||||
|
"attachmentUploading": "Uploading…",
|
||||||
|
"removeAttachment": "Remove attachment",
|
||||||
|
"attachmentLimitReached": "You can attach up to {count} files.",
|
||||||
|
"attachmentTooLarge": "\"{name}\" exceeds the 10MB limit.",
|
||||||
|
"attachmentUploadFailed": "Failed to upload \"{name}\".",
|
||||||
"submitButton": "Submit",
|
"submitButton": "Submit",
|
||||||
"submitting": "Submitting…",
|
"submitting": "Submitting…",
|
||||||
"submitSuccess": "Thanks! We received your message.",
|
"submitSuccess": "Thanks! We received your message.",
|
||||||
|
|||||||
@@ -462,6 +462,14 @@
|
|||||||
"titlePlaceholder": "Résumé court du problème ou de l'idée",
|
"titlePlaceholder": "Résumé court du problème ou de l'idée",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"descriptionPlaceholder": "Ce qui s'est passé, ce que vous attendiez, et les étapes pour reproduire (si bug) — ou les détails de votre suggestion/question.",
|
"descriptionPlaceholder": "Ce qui s'est passé, ce que vous attendiez, et les étapes pour reproduire (si bug) — ou les détails de votre suggestion/question.",
|
||||||
|
"attachmentsLabel": "Pièces jointes",
|
||||||
|
"attachmentsHint": "Captures d'écran ou fichiers, jusqu'à {count}. Images, PDF, texte, JSON ou zip — 10 Mo max chacun.",
|
||||||
|
"attachmentAdd": "Ajouter un fichier",
|
||||||
|
"attachmentUploading": "Envoi…",
|
||||||
|
"removeAttachment": "Retirer la pièce jointe",
|
||||||
|
"attachmentLimitReached": "Vous pouvez joindre jusqu'à {count} fichiers.",
|
||||||
|
"attachmentTooLarge": "\"{name}\" dépasse la limite de 10 Mo.",
|
||||||
|
"attachmentUploadFailed": "Échec de l'envoi de \"{name}\".",
|
||||||
"submitButton": "Envoyer",
|
"submitButton": "Envoyer",
|
||||||
"submitting": "Envoi…",
|
"submitting": "Envoi…",
|
||||||
"submitSuccess": "Merci ! Nous avons reçu votre message.",
|
"submitSuccess": "Merci ! Nous avons reçu votre message.",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.49.0",
|
"version": "0.49.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.49.0",
|
"version": "0.49.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "support_ticket_attachments" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"ticket_id" text NOT NULL,
|
||||||
|
"storage_key" text NOT NULL,
|
||||||
|
"content_type" text NOT NULL,
|
||||||
|
"size_bytes" integer NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "support_ticket_attachments" ADD CONSTRAINT "support_ticket_attachments_ticket_id_support_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."support_tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "support_ticket_attachments_ticket_idx" ON "support_ticket_attachments" USING btree ("ticket_id");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -323,6 +323,13 @@
|
|||||||
"when": 1784366396485,
|
"when": 1784366396485,
|
||||||
"tag": "0045_overjoyed_moondragon",
|
"tag": "0045_overjoyed_moondragon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784369252969,
|
||||||
|
"tag": "0046_curly_sabretooth",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, timestamp, index, pgEnum } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp, integer, index, pgEnum } from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
|
||||||
@@ -21,6 +21,22 @@ export const supportTickets = pgTable("support_tickets", {
|
|||||||
index("support_tickets_status_idx").on(t.status, t.createdAt),
|
index("support_tickets_status_idx").on(t.status, t.createdAt),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const supportTicketsRelations = relations(supportTickets, ({ one }) => ({
|
export const supportTicketAttachments = pgTable("support_ticket_attachments", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
ticketId: text("ticket_id").notNull().references(() => supportTickets.id, { onDelete: "cascade" }),
|
||||||
|
storageKey: text("storage_key").notNull(),
|
||||||
|
contentType: text("content_type").notNull(),
|
||||||
|
sizeBytes: integer("size_bytes").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
}, (t) => [
|
||||||
|
index("support_ticket_attachments_ticket_idx").on(t.ticketId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const supportTicketsRelations = relations(supportTickets, ({ one, many }) => ({
|
||||||
user: one(users, { fields: [supportTickets.userId], references: [users.id] }),
|
user: one(users, { fields: [supportTickets.userId], references: [users.id] }),
|
||||||
|
attachments: many(supportTicketAttachments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const supportTicketAttachmentsRelations = relations(supportTicketAttachments, ({ one }) => ({
|
||||||
|
ticket: one(supportTickets, { fields: [supportTicketAttachments.ticketId], references: [supportTickets.id] }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user