From 23b00b2cfc9f01b3591e7834e9f5436244059694 Mon Sep 17 00:00:00 2001 From: grabowski Date: Tue, 14 Apr 2026 16:42:50 +0700 Subject: [PATCH] Add feature requests page with upvotes and admin status workflow - 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) --- src/lib/components/layout/Sidebar.svelte | 10 + src/lib/server/db/schema.ts | 45 +++ src/lib/types/index.ts | 7 + .../(app)/feature-requests/+page.server.ts | 168 +++++++++++ .../(app)/feature-requests/+page.svelte | 272 ++++++++++++++++++ 5 files changed, 502 insertions(+) create mode 100644 src/routes/(app)/feature-requests/+page.server.ts create mode 100644 src/routes/(app)/feature-requests/+page.svelte diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index d9db9bf..a2164e2 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -34,6 +34,16 @@ Dashboard + + + + + Feature Requests + + {#if companies.length > 0}
Companies diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 32c4c73..aa966a3 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -609,6 +609,51 @@ export const shippingAccounts = pgTable( (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) ────────────────────────── export const companyLogEventEnum = pgEnum('company_log_event', [ diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index d06f701..f679cd6 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -7,6 +7,13 @@ export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled'; export type PayslipStatus = 'draft' | 'finalized' | 'paid'; export type TxDirection = 'credit' | 'debit'; 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. diff --git a/src/routes/(app)/feature-requests/+page.server.ts b/src/routes/(app)/feature-requests/+page.server.ts new file mode 100644 index 0000000..5e8629b --- /dev/null +++ b/src/routes/(app)/feature-requests/+page.server.ts @@ -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`( + SELECT COUNT(*)::int FROM ${featureRequestVotes} + WHERE ${featureRequestVotes.requestId} = ${featureRequests.id} + )`, + myVote: sql`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 }; + } +}; diff --git a/src/routes/(app)/feature-requests/+page.svelte b/src/routes/(app)/feature-requests/+page.svelte new file mode 100644 index 0000000..e511ce6 --- /dev/null +++ b/src/routes/(app)/feature-requests/+page.svelte @@ -0,0 +1,272 @@ + + + + Feature Requests - {data.appName} + + +
+
+
+

Feature Requests

+

+ Suggest improvements and upvote others' ideas. +

+
+ +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+
+ {#each FILTER_PILLS as p} + + {p.label} + + {/each} +
+ +
+ + {#if data.requests.length === 0} +
+

No feature requests yet.

+
+ {:else} +
    + {#each data.requests as req} +
  • +
    + +
    async ({ update }) => { + await update({ reset: false }); + }} + class="flex-shrink-0" + > + + +
    + + +
    +
    +

    {req.title}

    + + {STATUS_LABELS[req.status as FeatureRequestStatus]} + +
    + {#if req.description} +

    {req.description}

    + {/if} + {#if req.statusNote} +

    + Note from admin: {req.statusNote} +

    + {/if} +

    + Submitted by {req.submitterName ?? req.submitterEmail ?? '—'} · {timeAgo(req.createdAt)} +

    + + + {#if data.isSystemAdmin} +
    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" + > + + + + +
    + {/if} + + + {#if data.isSystemAdmin} +
    { + if (!confirm('Delete this feature request?')) { + cancel(); + return; + } + return async ({ update }) => { + await update({ reset: false }); + }; + }} + class="mt-2" + > + + +
    + {/if} +
    +
    +
  • + {/each} +
+ {/if} +
+ + +{#if showCreate} +
+
+

New Feature Request

+
async ({ update }) => { + await update(); + showCreate = false; + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if}