feat: fix channel scanning bugs, add package tags, and kickstarters tab

Bug fixes:
- Fix channels not being scanned by paginating TDLib getChats (was only
  loading first batch, additional channels were unknown to TDLib)
- Add per-channel getChat pre-load as safety net before scanning
- Fix preview pictures not loading by checking previewData instead of
  previewMsgId for hasPreview flag
- Prevent previewMsgId from being set when preview download fails

Package Tags:
- Add tags Text[] column to Package with migration backfilling from
  channel categories
- Worker auto-inherits source channel category as initial tag
- Tag filter dropdown and Tags column in STL Files table
- Server actions for individual and bulk tag editing

Kickstarters Tab:
- New KickstarterHost, Kickstarter, and KickstarterPackage models
- Full CRUD with delivery status, payment status, host management
- Package linking (many-to-many with existing packages)
- Sidebar entry with Gift icon
- Table with search, filters, modal forms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 18:17:44 +01:00
parent e2dd3bb9d0
commit 5fd341dfc4
23 changed files with 1375 additions and 32 deletions

View File

@@ -0,0 +1,148 @@
"use server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { kickstarterSchema, kickstarterHostSchema } from "@/schemas/kickstarter.schema";
import { revalidatePath } from "next/cache";
import type { ActionResult } from "@/types/api.types";
const REVALIDATE_PATH = "/kickstarters";
export async function createKickstarter(
input: unknown
): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = kickstarterSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
const ks = await prisma.kickstarter.create({
data: {
name: parsed.data.name,
link: parsed.data.link || null,
filesUrl: parsed.data.filesUrl || null,
deliveryStatus: parsed.data.deliveryStatus,
paymentStatus: parsed.data.paymentStatus,
hostId: parsed.data.hostId || null,
notes: parsed.data.notes || null,
userId: session.user.id,
},
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: { id: ks.id } };
} catch {
return { success: false, error: "Failed to create kickstarter" };
}
}
export async function updateKickstarter(
id: string,
input: unknown
): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = kickstarterSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
const existing = await prisma.kickstarter.findFirst({
where: { id, userId: session.user.id },
});
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.kickstarter.update({
where: { id },
data: {
name: parsed.data.name,
link: parsed.data.link || null,
filesUrl: parsed.data.filesUrl || null,
deliveryStatus: parsed.data.deliveryStatus,
paymentStatus: parsed.data.paymentStatus,
hostId: parsed.data.hostId || null,
notes: parsed.data.notes || null,
},
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update kickstarter" };
}
}
export async function deleteKickstarter(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.kickstarter.findFirst({
where: { id, userId: session.user.id },
});
if (!existing) return { success: false, error: "Not found" };
try {
await prisma.kickstarter.delete({ where: { id } });
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to delete kickstarter" };
}
}
export async function createHost(
input: unknown
): Promise<ActionResult<{ id: string; name: string }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const parsed = kickstarterHostSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Validation failed" };
try {
const host = await prisma.kickstarterHost.create({
data: { name: parsed.data.name },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: { id: host.id, name: host.name } };
} catch (err: unknown) {
if (
err instanceof Error &&
err.message.includes("Unique constraint")
) {
return { success: false, error: "A host with that name already exists" };
}
return { success: false, error: "Failed to create host" };
}
}
export async function linkPackages(
kickstarterId: string,
packageIds: string[]
): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const existing = await prisma.kickstarter.findFirst({
where: { id: kickstarterId, userId: session.user.id },
});
if (!existing) return { success: false, error: "Not found" };
try {
// Replace all linked packages
await prisma.$transaction([
prisma.kickstarterPackage.deleteMany({
where: { kickstarterId },
}),
...packageIds.map((packageId) =>
prisma.kickstarterPackage.create({
data: { kickstarterId, packageId },
})
),
]);
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to link packages" };
}
}