Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
import { eq } from "drizzle-orm";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db, WebsitePoll } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { PollForm } from "../poll-form";
import { PollQuestionsManager } from "./poll-questions";
import { PollResultsView } from "./poll-results";
export default async function PollDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = await params;
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.POLLS_EDIT, session.user.rank)) {
redirect("/admin/polls");
}
const pollId = parseInt(id, 10);
if (Number.isNaN(pollId)) notFound();
const t = await getTranslations("pages.admin.polls");
const tHubs = await getTranslations("pages.admin.hubs");
const poll = await db.query.WebsitePoll.findFirst({
where: eq(WebsitePoll.id, pollId),
with: {
questions: {
orderBy: (q, { asc }) => [asc(q.sortOrder)],
with: {
votes: true,
},
},
},
});
if (!poll) notFound();
const totalVoters = new Set(
poll.questions.flatMap((q) => q.votes.map((v) => v.userId)),
).size;
const statusLabel: Record<string, string> = {
active: t("statusActive"),
draft: t("statusDraft"),
closed: t("statusClosed"),
};
return (
<div className="space-y-6">
<div>
<AdminBreadcrumb
items={[
{ label: tHubs("tabs.polls"), href: "/admin/polls" },
{ label: poll.title },
]}
/>
<div className="flex items-center gap-3">
<h1 className="m-0 text-2xl font-extrabold text-[var(--admin-text)]">
{poll.title}
</h1>
<Badge
variant={
poll.status === "active"
? "default"
: poll.status === "closed"
? "outline"
: "secondary"
}
>
{statusLabel[poll.status] ?? poll.status}
</Badge>
</div>
{poll.description && (
<p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]">
{poll.description}
</p>
)}
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
{t("statVoters")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalVoters}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
{t("statQuestions")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{poll.questions.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
{t("statVotes")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{poll.questions.reduce((sum, q) => sum + q.votes.length, 0)}
</div>
</CardContent>
</Card>
</div>
{/* Edit form */}
<PollForm
defaultValues={{
id: poll.id,
title: poll.title,
description: poll.description,
status: poll.status as "draft" | "active" | "closed",
showResults: poll.showResults,
multipleChoice: poll.multipleChoice,
startsAt: poll.startsAt,
endsAt: poll.endsAt,
}}
/>
{/* Questions */}
<PollQuestionsManager
pollId={poll.id}
questions={poll.questions.map((q) => ({
id: q.id,
question: q.question,
type: q.type,
sortOrder: q.sortOrder,
options: q.options,
}))}
/>
{/* Results */}
{poll.questions.length > 0 && (
<PollResultsView
questions={poll.questions.map((q) => ({
id: q.id,
question: q.question,
options: q.options,
votes: q.votes.map((v) => v.answer),
}))}
/>
)}
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { GripVertical, Pencil, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
addPollQuestion,
deletePollQuestion,
updatePollQuestion,
} from "@/actions/polls";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useServerAction } from "@/hooks/use-server-action";
import type { PollQuestionInput } from "@/lib/validators/poll";
interface Question {
id: number;
question: string;
type: string;
sortOrder: number;
options: string;
}
interface Props {
pollId: number;
questions: Question[];
}
type PollQuestionUpdateInput = PollQuestionInput & { id: number };
export function PollQuestionsManager({ pollId, questions }: Props) {
const t = useTranslations("pages.admin.polls");
const { run, isPending } = useServerAction();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<PollQuestionInput>({
pollId,
question: "",
type: "single",
sortOrder: questions.length,
options: "",
});
function resetForm() {
setForm({
pollId,
question: "",
type: "single",
sortOrder: questions.length,
options: "",
});
setEditingId(null);
setShowForm(false);
}
function handleEdit(q: Question) {
setForm({
pollId,
question: q.question,
type: q.type as PollQuestionInput["type"],
sortOrder: q.sortOrder,
options: q.options,
});
setEditingId(q.id);
setShowForm(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (editingId) {
run(
() =>
updatePollQuestion({
id: editingId,
...form,
} as PollQuestionUpdateInput),
{
successMessage: "Question updated!",
},
);
} else {
run(() => addPollQuestion(form), {
successMessage: "Question added!",
});
}
resetForm();
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{t("questionsTitle", { count: questions.length })}
</CardTitle>
<Button
size="sm"
onClick={() => {
resetForm();
setShowForm(!showForm);
}}
>
<Plus className="h-4 w-4 mr-1" /> {t("addQuestion")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{showForm && (
<form
onSubmit={handleSubmit}
className="border rounded-lg p-4 space-y-3"
>
<div className="space-y-1">
<Label>Question</Label>
<Input
value={form.question}
onChange={(e) => setForm({ ...form, question: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Type</Label>
<Select
value={form.type}
onValueChange={(v) =>
setForm({ ...form, type: v as PollQuestionInput["type"] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single">Single Choice</SelectItem>
<SelectItem value="multiple">Multiple Choice</SelectItem>
<SelectItem value="text">Free Text</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Sort Order</Label>
<Input
type="number"
value={form.sortOrder}
onChange={(e) =>
setForm({ ...form, sortOrder: Number(e.target.value) })
}
/>
</div>
</div>
<div className="space-y-1">
<Label>Options (one per line)</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm min-h-[80px]"
value={form.options}
onChange={(e) => setForm({ ...form, options: e.target.value })}
placeholder={"Option A\nOption B\nOption C"}
/>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={resetForm}
>
Cancel
</Button>
<Button type="submit" size="sm" disabled={isPending}>
{editingId ? "Update" : "Add"}
</Button>
</div>
</form>
)}
{questions.length === 0 && !showForm && (
<div className="admin-empty">{t("noQuestions")}</div>
)}
<div className="space-y-2">
{questions.map((q, i) => (
<div
key={q.id}
className="flex items-start gap-3 border rounded-lg px-4 py-3"
>
<GripVertical className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground">
Q{i + 1}
</span>
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
{q.type}
</span>
</div>
<p className="text-sm font-medium">{q.question}</p>
{q.options && (
<div className="mt-1 text-xs text-muted-foreground">
{q.options
.split("\n")
.filter(Boolean)
.map((opt) => (
<span
key={opt}
className="inline-block mr-2 bg-muted px-1.5 py-0.5 rounded mb-1"
>
{opt}
</span>
))}
</div>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(q)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
run(() => deletePollQuestion({ id: q.id }), {
successMessage: "Question deleted",
})
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface QuestionResult {
id: number;
question: string;
options: string;
votes: string[]; // array of answer strings
}
interface Props {
questions: QuestionResult[];
}
export function PollResultsView({ questions }: Props) {
const t = useTranslations("pages.admin.polls");
return (
<Card>
<CardHeader>
<CardTitle>{t("resultsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{questions.map((q) => {
const options = q.options.split("\n").filter(Boolean);
const totalVotes = q.votes.length;
// Count votes per option
const voteCounts = new Map<string, number>();
for (const opt of options) voteCounts.set(opt, 0);
for (const vote of q.votes) {
voteCounts.set(vote, (voteCounts.get(vote) ?? 0) + 1);
}
return (
<div key={q.id}>
<h4 className="text-sm font-medium mb-3">{q.question}</h4>
{options.length > 0 ? (
<div className="space-y-2">
{options.map((opt) => {
const count = voteCounts.get(opt) ?? 0;
const pct =
totalVotes > 0
? Math.round((count / totalVotes) * 100)
: 0;
return (
<div key={opt}>
<div className="flex justify-between text-xs mb-1">
<span>{opt}</span>
<span className="text-muted-foreground">
{count} votes ({pct}%)
</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
) : (
<div className="text-sm text-muted-foreground">
{totalVotes} free-text responses
</div>
)}
<p className="text-xs text-muted-foreground mt-2">
{totalVotes} total vote{totalVotes !== 1 ? "s" : ""}
</p>
</div>
);
})}
</CardContent>
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { PollForm } from "../poll-form";
export default async function CreatePollPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.POLLS_EDIT, session.user.rank)) {
redirect("/admin/polls");
}
const t = await getTranslations("pages.admin.polls");
return (
<div className="space-y-6">
<div>
<h1 className="m-0 text-2xl font-extrabold text-[var(--admin-text)]">
{t("createTitle")}
</h1>
<p className="m-0 mt-1 text-sm text-[var(--admin-text-muted)]">
{t("createSubtitle")}
</p>
</div>
<PollForm />
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { count, desc, like } from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { fetchAdminList } from "@/lib/admin-list";
import { db, WebsitePoll } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { PollsTable } from "./polls-table";
export default async function PollsPage({
params: paramsPromise,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<Record<string, string>>;
}) {
const t = await getTranslations("pages.admin.polls");
const result = await fetchAdminList(
{
permission: PERMS.POLLS_VIEW,
fetch: async ({ search, page, perPage }) => {
const where = search
? like(WebsitePoll.title, `%${search}%`)
: undefined;
const [rows, totals] = await Promise.all([
db
.select({
id: WebsitePoll.id,
title: WebsitePoll.title,
status: WebsitePoll.status,
showResults: WebsitePoll.showResults,
multipleChoice: WebsitePoll.multipleChoice,
startsAt: WebsitePoll.startsAt,
endsAt: WebsitePoll.endsAt,
createdAt: WebsitePoll.createdAt,
})
.from(WebsitePoll)
.where(where)
.orderBy(desc(WebsitePoll.createdAt))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: count() }).from(WebsitePoll).where(where),
]);
return { rows, total: totals[0]?.total ?? 0 };
},
},
paramsPromise,
searchParams,
);
return (
<div className="space-y-6">
<div className="flex justify-end">
<Button variant="default" asChild>
<Link href="/admin/polls/create">{t("newPoll")}</Link>
</Button>
</div>
<PollsTable
data={{
rows: result.rows,
total: result.total,
page: result.page,
perPage: result.perPage,
lastPage: result.lastPage,
}}
/>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { createPoll, updatePoll } from "@/actions/polls";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import type { CreatePollInput, UpdatePollInput } from "@/lib/validators/poll";
interface PollFormProps {
defaultValues?: Partial<CreatePollInput> & { id?: number };
}
interface PollFormValues {
title: string;
description: string | null;
status: string;
showResults: number;
multipleChoice: number;
startsAt?: Date | null;
endsAt?: Date | null;
}
export function PollForm({ defaultValues }: PollFormProps) {
const t = useTranslations("pages.admin.polls");
const { run, isPending } = useServerAction();
const isEditing = !!defaultValues?.id;
const form = useForm<PollFormValues>({
defaultValues: {
title: "",
description: "",
status: "draft",
showResults: 1,
multipleChoice: 0,
...defaultValues,
},
});
function onSubmit(data: PollFormValues) {
if (isEditing) {
run(
() =>
updatePoll({ id: defaultValues.id, ...data } as UpdatePollInput & {
id: number;
}),
{
successMessage: t("toastUpdated"),
},
);
} else {
run(() => createPoll(data as CreatePollInput), {
successMessage: t("toastCreated"),
onSuccess: (result) => {
const data = result.data as Record<string, unknown> | undefined;
if (data?.id) {
window.location.href = `/admin/polls/${data.id}`;
}
},
});
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>
{isEditing ? t("formEditTitle") : t("formDetailsTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t("formTitle")}</Label>
<Input id="title" {...form.register("title")} />
{form.formState.errors.title && (
<p className="text-sm text-destructive">
{form.formState.errors.title.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("formDescription")}</Label>
<Textarea
id="description"
rows={3}
{...form.register("description")}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="status">{t("colStatus")}</Label>
<Select
value={form.watch("status")}
onValueChange={(v) =>
form.setValue("status", v as CreatePollInput["status"])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">{t("statusDraft")}</SelectItem>
<SelectItem value="active">{t("statusActive")}</SelectItem>
<SelectItem value="closed">{t("statusClosed")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("formStartsAt")}</Label>
<DateTimePicker
value={form.watch("startsAt")}
onChange={(d) =>
form.setValue("startsAt", d, { shouldDirty: true })
}
placeholder={t("formSelectStart")}
/>
</div>
<div className="space-y-2">
<Label>{t("formEndsAt")}</Label>
<DateTimePicker
value={form.watch("endsAt")}
onChange={(d) =>
form.setValue("endsAt", d, { shouldDirty: true })
}
placeholder={t("formSelectEnd")}
/>
</div>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
checked={form.watch("showResults") === 1}
onCheckedChange={(v) => form.setValue("showResults", v ? 1 : 0)}
/>
<Label>{t("formShowResults")}</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={form.watch("multipleChoice") === 1}
onCheckedChange={(v) =>
form.setValue("multipleChoice", v ? 1 : 0)
}
/>
<Label>{t("formAllowMultiple")}</Label>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending
? t("formSaving")
: isEditing
? t("formUpdate")
: t("formCreate")}
</Button>
</div>
</CardContent>
</Card>
</form>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { Pencil, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { deletePoll } from "@/actions/polls";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useServerAction } from "@/hooks/use-server-action";
import type { DataTableColumn, PaginatedResult } from "@/types/common";
interface PollRow {
id: number;
title: string;
status: string;
showResults: number;
multipleChoice: number;
startsAt: Date | null;
endsAt: Date | null;
createdAt: Date;
}
const statusVariants: Record<string, "default" | "secondary" | "outline"> = {
active: "default",
draft: "secondary",
closed: "outline",
};
interface PollsTableProps {
data: PaginatedResult<PollRow>;
}
export function PollsTable({ data }: PollsTableProps) {
const t = useTranslations("pages.admin.polls");
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const statusLabel: Record<string, string> = {
active: t("statusActive"),
draft: t("statusDraft"),
closed: t("statusClosed"),
};
async function handleDelete(row: PollRow) {
const ok = await confirm({
title: "Delete poll",
description: `Delete "${row.title}"? This cannot be undone.`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deletePoll({ id: row.id }), {
successMessage: "Poll deleted!",
errorMessage: "Failed to delete poll.",
});
}
const columns: DataTableColumn<PollRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{
key: "title",
label: t("colTitle"),
sortable: true,
render: (value) => <span className="font-medium">{String(value)}</span>,
},
{
key: "status",
label: t("colStatus"),
sortable: true,
render: (value) => {
const key = String(value);
return (
<Badge variant={statusVariants[key] ?? "secondary"}>
{statusLabel[key] ?? key}
</Badge>
);
},
},
{
key: "multipleChoice",
label: t("colType"),
render: (value) => (
<span className="text-sm">
{Number(value) ? t("typeMultiple") : t("typeSingle")}
</span>
),
},
{
key: "createdAt",
label: t("colCreated"),
sortable: true,
render: (value) => (
<span className="text-sm">
{new Date(value as unknown as string).toLocaleDateString()}
</span>
),
},
{
key: "actions",
label: t("colActions"),
render: (_, row) => (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/polls/${row.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
disabled={isPending}
onClick={() => handleDelete(row)}
aria-label="Delete poll"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
},
];
return (
<>
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
/>
{confirmDialog}
</>
);
}