Add feature requests page with upvotes and admin status workflow
Validate / validate (push) Successful in 22s

- New /feature-requests route accessible to all logged-in users via sidebar nav
- feature_requests + feature_request_votes tables (one vote per user per request)
- Submit form (modal), upvote toggle, filter by status, sort by votes/newest
- System admins can change status (open / in_review / waiting_for_checks / in_progress / resolved / closed) with optional note
- Submitter auto-votes their own request on creation
- Admin or original submitter can delete a request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 16:42:50 +07:00
parent b6f07fe4df
commit 23b00b2cfc
5 changed files with 502 additions and 0 deletions
+10
View File
@@ -34,6 +34,16 @@
Dashboard Dashboard
</a> </a>
<a
href="/feature-requests"
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Feature Requests
</a>
{#if companies.length > 0} {#if companies.length > 0}
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Companies Companies
+45
View File
@@ -609,6 +609,51 @@ export const shippingAccounts = pgTable(
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)] (table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
); );
// ── Feature Requests ───────────────────────────────────
export const featureRequestStatusEnum = pgEnum('feature_request_status', [
'open',
'in_review',
'waiting_for_checks',
'in_progress',
'resolved',
'closed'
]);
export const featureRequests = pgTable(
'feature_requests',
{
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
description: text('description'),
status: featureRequestStatusEnum('status').notNull().default('open'),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id, { onDelete: 'set null' }),
statusChangedBy: text('status_changed_by').references(() => users.id, { onDelete: 'set null' }),
statusChangedAt: timestamp('status_changed_at', { withTimezone: true }),
statusNote: text('status_note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('feature_requests_status_idx').on(table.status)]
);
export const featureRequestVotes = pgTable(
'feature_request_votes',
{
id: uuid('id').primaryKey().defaultRandom(),
requestId: uuid('request_id')
.notNull()
.references(() => featureRequests.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('feature_request_votes_request_user_idx').on(table.requestId, table.userId)]
);
// ── Company Log (Audit Trail) ────────────────────────── // ── Company Log (Audit Trail) ──────────────────────────
export const companyLogEventEnum = pgEnum('company_log_event', [ export const companyLogEventEnum = pgEnum('company_log_event', [
+7
View File
@@ -7,6 +7,13 @@ export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
export type PayslipStatus = 'draft' | 'finalized' | 'paid'; export type PayslipStatus = 'draft' | 'finalized' | 'paid';
export type TxDirection = 'credit' | 'debit'; export type TxDirection = 'credit' | 'debit';
export type IntegrationProvider = 'kasikorn_kbiz' | 'etherfi' | 'manual'; export type IntegrationProvider = 'kasikorn_kbiz' | 'etherfi' | 'manual';
export type FeatureRequestStatus =
| 'open'
| 'in_review'
| 'waiting_for_checks'
| 'in_progress'
| 'resolved'
| 'closed';
/** /**
* Hierarchical roles — only these ranks. `hr` is orthogonal and excluded. * Hierarchical roles — only these ranks. `hr` is orthogonal and excluded.
@@ -0,0 +1,168 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { featureRequests, featureRequestVotes, users } from '$lib/server/db/schema.js';
import { eq, and, sql, desc } from 'drizzle-orm';
import { requireAuth } from '$lib/server/authorization.js';
import type { FeatureRequestStatus } from '$lib/types/index.js';
const ALL_STATUSES: FeatureRequestStatus[] = [
'open',
'in_review',
'waiting_for_checks',
'in_progress',
'resolved',
'closed'
];
export const load: PageServerLoad = async ({ locals, url }) => {
const user = requireAuth(locals);
const status = url.searchParams.get('status') ?? 'all';
const sort = url.searchParams.get('sort') ?? 'votes';
const baseSelect = db
.select({
id: featureRequests.id,
title: featureRequests.title,
description: featureRequests.description,
status: featureRequests.status,
submittedBy: featureRequests.submittedBy,
submitterName: users.displayName,
submitterEmail: users.email,
statusNote: featureRequests.statusNote,
statusChangedAt: featureRequests.statusChangedAt,
createdAt: featureRequests.createdAt,
voteCount: sql<number>`(
SELECT COUNT(*)::int FROM ${featureRequestVotes}
WHERE ${featureRequestVotes.requestId} = ${featureRequests.id}
)`,
myVote: sql<boolean>`EXISTS (
SELECT 1 FROM ${featureRequestVotes}
WHERE ${featureRequestVotes.requestId} = ${featureRequests.id}
AND ${featureRequestVotes.userId} = ${user.id}
)`
})
.from(featureRequests)
.leftJoin(users, eq(featureRequests.submittedBy, users.id));
const filtered =
status !== 'all' && (ALL_STATUSES as string[]).includes(status)
? baseSelect.where(eq(featureRequests.status, status as FeatureRequestStatus))
: baseSelect;
const ordered =
sort === 'newest'
? filtered.orderBy(desc(featureRequests.createdAt))
: filtered.orderBy(
desc(sql`(SELECT COUNT(*) FROM ${featureRequestVotes} WHERE ${featureRequestVotes.requestId} = ${featureRequests.id})`),
desc(featureRequests.createdAt)
);
const requests = await ordered;
return {
requests,
filters: { status, sort },
isSystemAdmin: user.isSystemAdmin
};
};
export const actions: Actions = {
create: async ({ request, locals }) => {
const user = requireAuth(locals);
const formData = await request.formData();
const title = formData.get('title')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
if (!title) return fail(400, { error: 'Title is required' });
if (title.length > 200) return fail(400, { error: 'Title is too long' });
const [created] = await db
.insert(featureRequests)
.values({ title, description, submittedBy: user.id })
.returning({ id: featureRequests.id });
// Auto-upvote by submitter
await db.insert(featureRequestVotes).values({ requestId: created.id, userId: user.id });
return { success: true, createdId: created.id };
},
toggleVote: async ({ request, locals }) => {
const user = requireAuth(locals);
const formData = await request.formData();
const requestId = formData.get('requestId')?.toString();
if (!requestId) return fail(400, { error: 'Missing request ID' });
const existing = await db
.select({ id: featureRequestVotes.id })
.from(featureRequestVotes)
.where(
and(
eq(featureRequestVotes.requestId, requestId),
eq(featureRequestVotes.userId, user.id)
)
)
.limit(1);
if (existing.length > 0) {
await db.delete(featureRequestVotes).where(eq(featureRequestVotes.id, existing[0].id));
} else {
await db.insert(featureRequestVotes).values({ requestId, userId: user.id });
}
return { success: true };
},
updateStatus: async ({ request, locals }) => {
const user = requireAuth(locals);
if (!user.isSystemAdmin) {
return fail(403, { error: 'Only system admins can change status' });
}
const formData = await request.formData();
const requestId = formData.get('requestId')?.toString();
const newStatus = formData.get('status')?.toString() as FeatureRequestStatus | undefined;
const note = formData.get('statusNote')?.toString().trim() || null;
if (!requestId) return fail(400, { error: 'Missing request ID' });
if (!newStatus || !ALL_STATUSES.includes(newStatus)) {
return fail(400, { error: 'Invalid status' });
}
await db
.update(featureRequests)
.set({
status: newStatus,
statusChangedBy: user.id,
statusChangedAt: new Date(),
statusNote: note,
updatedAt: new Date()
})
.where(eq(featureRequests.id, requestId));
return { success: true };
},
delete: async ({ request, locals }) => {
const user = requireAuth(locals);
const formData = await request.formData();
const requestId = formData.get('requestId')?.toString();
if (!requestId) return fail(400, { error: 'Missing request ID' });
// Allow deletion by system admin OR original submitter
const [req] = await db
.select({ submittedBy: featureRequests.submittedBy })
.from(featureRequests)
.where(eq(featureRequests.id, requestId))
.limit(1);
if (!req) return fail(404, { error: 'Not found' });
if (!user.isSystemAdmin && req.submittedBy !== user.id) {
return fail(403, { error: 'You can only delete your own requests' });
}
await db.delete(featureRequests).where(eq(featureRequests.id, requestId));
return { success: true };
}
};
@@ -0,0 +1,272 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { timeAgo } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
import type { FeatureRequestStatus } from '$lib/types/index.js';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let showCreate = $state(false);
const STATUS_LABELS: Record<FeatureRequestStatus, string> = {
open: 'Open',
in_review: 'In Review',
waiting_for_checks: 'Waiting for Checks',
in_progress: 'In Progress',
resolved: 'Resolved',
closed: 'Closed'
};
const STATUS_BADGE: Record<FeatureRequestStatus, string> = {
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
in_review: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
waiting_for_checks: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
in_progress: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
resolved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
closed: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
};
const FILTER_PILLS: { value: string; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'open', label: 'Open' },
{ value: 'in_review', label: 'In Review' },
{ value: 'waiting_for_checks', label: 'Waiting' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' }
];
</script>
<svelte:head>
<title>Feature Requests - {data.appName}</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Feature Requests</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Suggest improvements and upvote others' ideas.
</p>
</div>
<button
onclick={() => (showCreate = true)}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
+ New Request
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{form.error}
</div>
{/if}
<!-- Filters + sort -->
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap gap-2">
{#each FILTER_PILLS as p}
<a
href="?status={p.value}&sort={data.filters.sort}"
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {data.filters.status === p.value
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{p.label}
</a>
{/each}
</div>
<div class="flex gap-2 text-xs">
<a
href="?status={data.filters.status}&sort=votes"
class="rounded px-2 py-1 {data.filters.sort === 'votes'
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
>
Top voted
</a>
<a
href="?status={data.filters.status}&sort=newest"
class="rounded px-2 py-1 {data.filters.sort === 'newest'
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
>
Newest
</a>
</div>
</div>
{#if data.requests.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No feature requests yet.</p>
</div>
{:else}
<ul class="space-y-3">
{#each data.requests as req}
<li class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-start gap-4">
<!-- Vote button -->
<form
method="POST"
action="?/toggleVote"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
class="flex-shrink-0"
>
<input type="hidden" name="requestId" value={req.id} />
<button
type="submit"
class="flex h-14 w-14 flex-col items-center justify-center rounded-md border-2 transition-colors {req.myVote
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-700'}"
title={req.myVote ? 'Remove vote' : 'Upvote'}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
<span class="mt-0.5 text-sm font-bold">{req.voteCount}</span>
</button>
</form>
<!-- Body -->
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<h3 class="font-medium text-gray-900 dark:text-white">{req.title}</h3>
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide {STATUS_BADGE[req.status as FeatureRequestStatus]}">
{STATUS_LABELS[req.status as FeatureRequestStatus]}
</span>
</div>
{#if req.description}
<p class="mt-1 text-sm text-gray-600 whitespace-pre-line dark:text-gray-300">{req.description}</p>
{/if}
{#if req.statusNote}
<p class="mt-2 rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900/50 dark:text-gray-400">
<span class="font-semibold">Note from admin:</span> {req.statusNote}
</p>
{/if}
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
Submitted by {req.submitterName ?? req.submitterEmail ?? '—'} · {timeAgo(req.createdAt)}
</p>
<!-- Admin status controls -->
{#if data.isSystemAdmin}
<form
method="POST"
action="?/updateStatus"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
}}
class="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 p-2 text-xs dark:border-gray-700"
>
<input type="hidden" name="requestId" value={req.id} />
<label class="flex items-center gap-1 text-gray-600 dark:text-gray-400">
Status:
<select
name="status"
class="rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
{#each Object.entries(STATUS_LABELS) as [val, label]}
<option value={val} selected={req.status === val}>{label}</option>
{/each}
</select>
</label>
<input
type="text"
name="statusNote"
placeholder="Optional note (e.g. ETA, blocker)"
value={req.statusNote ?? ''}
class="flex-1 min-w-32 rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<button type="submit" class="rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700">
Save
</button>
</form>
{/if}
<!-- Delete (admin can delete any) -->
{#if data.isSystemAdmin}
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (!confirm('Delete this feature request?')) {
cancel();
return;
}
return async ({ update }) => {
await update({ reset: false });
};
}}
class="mt-2"
>
<input type="hidden" name="requestId" value={req.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-800 dark:text-red-400">
Delete
</button>
</form>
{/if}
</div>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Create modal -->
{#if showCreate}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">New Feature Request</h2>
<form
method="POST"
action="?/create"
use:enhance={() => async ({ update }) => {
await update();
showCreate = false;
}}
>
<div class="mb-4">
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Title <span class="text-red-500">*</span>
</label>
<input
type="text"
id="title"
name="title"
required
maxlength="200"
placeholder="Short, descriptive summary"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div class="mb-4">
<label for="description" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<textarea
id="description"
name="description"
rows="5"
placeholder="What problem does this solve? What would the ideal experience look like?"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
></textarea>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showCreate = false)}
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Submit
</button>
</div>
</form>
</div>
</div>
{/if}