From ca0335671ca357cce4dcf1f7f44ac7401d249cf4 Mon Sep 17 00:00:00 2001 From: grabowski Date: Wed, 15 Apr 2026 11:49:04 +0700 Subject: [PATCH] Add company links page with CRUD and Links nav tab Co-Authored-By: Claude Opus 4.6 (1M context) --- .../companies/[companyId]/+layout.svelte | 1 + .../[companyId]/links/+page.server.ts | 308 +++++++++++ .../companies/[companyId]/links/+page.svelte | 494 ++++++++++++++++++ 3 files changed, 803 insertions(+) create mode 100644 src/routes/(app)/companies/[companyId]/links/+page.server.ts create mode 100644 src/routes/(app)/companies/[companyId]/links/+page.svelte diff --git a/src/routes/(app)/companies/[companyId]/+layout.svelte b/src/routes/(app)/companies/[companyId]/+layout.svelte index 65ba9c1..f7ec192 100644 --- a/src/routes/(app)/companies/[companyId]/+layout.svelte +++ b/src/routes/(app)/companies/[companyId]/+layout.svelte @@ -5,6 +5,7 @@ const tabs = $derived([ { href: `/companies/${data.company.id}`, label: 'Overview' }, + { href: `/companies/${data.company.id}/links`, label: 'Links' }, { href: `/companies/${data.company.id}/projects`, label: 'Projects' }, { href: `/companies/${data.company.id}/expenses`, label: 'Expenses' }, { href: `/companies/${data.company.id}/budget`, label: 'Budget' }, diff --git a/src/routes/(app)/companies/[companyId]/links/+page.server.ts b/src/routes/(app)/companies/[companyId]/links/+page.server.ts new file mode 100644 index 0000000..d1b4a93 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/links/+page.server.ts @@ -0,0 +1,308 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { companyLinks, userCompanyLinks, users } from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { validateLinkUrl, InvalidLinkUrlError } from '$lib/server/links/validate.js'; +import { and, asc, eq, isNull, sql } from 'drizzle-orm'; + +const CATEGORIES = [ + 'internal_tool', + 'communication', + 'social_media', + 'analytics', + 'banking', + 'government', + 'storage', + 'marketing', + 'development', + 'website', + 'other' +] as const; + +type Category = (typeof CATEGORIES)[number]; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +function parseCategory(v: FormDataEntryValue | null): Category | null { + const s = v?.toString(); + if (!s) return null; + return (CATEGORIES as readonly string[]).includes(s) ? (s as Category) : null; +} + +function extractFields(fd: FormData): { + title: string | null; + url: string | null; + category: Category | null; + customLabel: string | null; + description: string | null; +} { + return { + title: trimOrNull(fd.get('title')), + url: trimOrNull(fd.get('url')), + category: parseCategory(fd.get('category')), + customLabel: trimOrNull(fd.get('customLabel')), + description: trimOrNull(fd.get('description')) + }; +} + +type OrderPayload = { id: string; sortOrder: number }; + +function parseOrderPayload(raw: FormDataEntryValue | null): OrderPayload[] | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString()); + } catch { + return null; + } + if (!Array.isArray(parsed)) return null; + const out: OrderPayload[] = []; + for (const row of parsed) { + if (!row || typeof row !== 'object') return null; + const r = row as Record; + if (typeof r.id !== 'string' || typeof r.sortOrder !== 'number') return null; + out.push({ id: r.id, sortOrder: r.sortOrder }); + } + return out; +} + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + const { roles } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'user', + 'viewer', + 'hr', + 'accountant' + ]); + await parent(); + + const companyRows = await db + .select({ + id: companyLinks.id, + category: companyLinks.category, + customLabel: companyLinks.customLabel, + title: companyLinks.title, + url: companyLinks.url, + description: companyLinks.description, + faviconUrl: companyLinks.faviconUrl, + faviconFetchedAt: companyLinks.faviconFetchedAt, + sortOrder: companyLinks.sortOrder, + createdAt: companyLinks.createdAt, + updatedAt: companyLinks.updatedAt, + createdByName: users.displayName, + createdByEmail: users.email + }) + .from(companyLinks) + .leftJoin(users, eq(companyLinks.createdBy, users.id)) + .where(and(eq(companyLinks.companyId, params.companyId), isNull(companyLinks.deletedAt))) + .orderBy(asc(companyLinks.category), asc(companyLinks.sortOrder), asc(companyLinks.createdAt)); + + const userId = locals.user?.id; + const personalRows = userId + ? await db + .select() + .from(userCompanyLinks) + .where( + and(eq(userCompanyLinks.userId, userId), eq(userCompanyLinks.companyId, params.companyId)) + ) + .orderBy( + asc(userCompanyLinks.category), + asc(userCompanyLinks.sortOrder), + asc(userCompanyLinks.createdAt) + ) + : []; + + const canManage = roles.some((r) => r === 'admin' || r === 'manager'); + + return { + companyLinks: companyRows, + personalLinks: personalRows, + canManage + }; +}; + +async function nextSortOrder( + table: typeof companyLinks | typeof userCompanyLinks, + scope: Record +): Promise { + const conditions = Object.entries(scope).map(([k, v]) => + eq(table[k as keyof typeof table] as never, v as never) + ); + const [row] = await db + .select({ max: sql`coalesce(max(${table.sortOrder}), -1)::int` }) + .from(table) + .where(and(...conditions)); + return (row?.max ?? -1) + 1; +} + +export const actions: Actions = { + addCompanyLink: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); + const fd = await request.formData(); + const { title, url, category, customLabel, description } = extractFields(fd); + + if (!title) return fail(400, { action: 'addCompanyLink', error: 'Title is required' }); + if (!category) return fail(400, { action: 'addCompanyLink', error: 'Invalid category' }); + if (!url) return fail(400, { action: 'addCompanyLink', error: 'URL is required' }); + + let validUrl: string; + try { + validUrl = validateLinkUrl(url); + } catch (err) { + const msg = err instanceof InvalidLinkUrlError ? err.message : 'Invalid URL'; + return fail(400, { action: 'addCompanyLink', error: msg }); + } + + const sortOrder = await nextSortOrder(companyLinks, { + companyId: params.companyId, + category + }); + + const [inserted] = await db + .insert(companyLinks) + .values({ + companyId: params.companyId, + category, + customLabel, + title, + url: validUrl, + description, + sortOrder, + createdBy: user.id + }) + .returning({ id: companyLinks.id }); + + await logCompanyEvent( + params.companyId, + user.id, + 'link_added', + `Link "${title}" added`, + { linkId: inserted.id, category, url: validUrl } + ); + + return { success: true, action: 'addCompanyLink' }; + }, + + updateCompanyLink: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'updateCompanyLink', error: 'Link id is required' }); + + const [existing] = await db + .select() + .from(companyLinks) + .where( + and( + eq(companyLinks.id, id), + eq(companyLinks.companyId, params.companyId), + isNull(companyLinks.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Link not found'); + + const { title, url, category, customLabel, description } = extractFields(fd); + if (!title) return fail(400, { action: 'updateCompanyLink', error: 'Title is required' }); + if (!category) return fail(400, { action: 'updateCompanyLink', error: 'Invalid category' }); + if (!url) return fail(400, { action: 'updateCompanyLink', error: 'URL is required' }); + + let validUrl: string; + try { + validUrl = validateLinkUrl(url); + } catch (err) { + const msg = err instanceof InvalidLinkUrlError ? err.message : 'Invalid URL'; + return fail(400, { action: 'updateCompanyLink', error: msg }); + } + + const urlChanged = validUrl !== existing.url; + await db + .update(companyLinks) + .set({ + title, + url: validUrl, + category, + customLabel, + description, + faviconUrl: urlChanged ? null : existing.faviconUrl, + faviconFetchedAt: urlChanged ? null : existing.faviconFetchedAt, + updatedAt: new Date() + }) + .where(eq(companyLinks.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'link_updated', + `Link "${title}" updated`, + { linkId: id, category } + ); + + return { success: true, action: 'updateCompanyLink' }; + }, + + deleteCompanyLink: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'deleteCompanyLink', error: 'Link id is required' }); + + const [existing] = await db + .select({ id: companyLinks.id, title: companyLinks.title }) + .from(companyLinks) + .where( + and( + eq(companyLinks.id, id), + eq(companyLinks.companyId, params.companyId), + isNull(companyLinks.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Link not found'); + + await db + .update(companyLinks) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(companyLinks.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'link_deleted', + `Link "${existing.title}" deleted`, + { linkId: id } + ); + + return { success: true, action: 'deleteCompanyLink' }; + }, + + reorderCompanyLinks: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']); + const fd = await request.formData(); + const payload = parseOrderPayload(fd.get('orders')); + if (!payload) return fail(400, { action: 'reorderCompanyLinks', error: 'Invalid order payload' }); + + await db.transaction(async (tx) => { + for (const { id, sortOrder } of payload) { + await tx + .update(companyLinks) + .set({ sortOrder, updatedAt: new Date() }) + .where( + and( + eq(companyLinks.id, id), + eq(companyLinks.companyId, params.companyId), + isNull(companyLinks.deletedAt) + ) + ); + } + }); + + return { success: true, action: 'reorderCompanyLinks' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/links/+page.svelte b/src/routes/(app)/companies/[companyId]/links/+page.svelte new file mode 100644 index 0000000..b33a55e --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/links/+page.svelte @@ -0,0 +1,494 @@ + + + + Links - {data.company.name} + + +
+
+

Links

+

+ Internal tools, dashboards, banking portals, social handles, and anything else the team needs + quick access to. Company links are curated by admin/manager; personal bookmarks are private to + you. +

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+ + +
+ + + {#if currentLinks.length > 0} +
+ + {#each categoriesPresent as cat (cat)} + + {/each} +
+ {/if} + + {#if activeTab === 'company'} + + {#if data.canManage} +
+
+

Add Company Link

+ +
+ + {#if showAddForm} +
async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') { + showAddForm = false; + const formEl = document.querySelector( + 'form[action="?/addCompanyLink"]' + ) as HTMLFormElement | null; + formEl?.reset(); + } + }} + class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
+ {/if} + + {#if filteredLinks.length === 0} +
+

+ {#if data.canManage} + No company links yet. Click "+ New Link" to add the first one. + {:else} + No company links have been added yet. + {/if} +

+
+ {:else} +
+ {#each filteredLinks as link (link.id)} +
+
+ {#if link.faviconUrl} + + {:else} +
+ {firstLetter(link.title)} +
+ {/if} +
+ + {link.title} + +

+ {hostnameOf(link.url)} +

+
+
+ +
+ + {CATEGORY_LABELS[link.category] ?? link.category} + + {#if link.customLabel} + ยท {link.customLabel} + {/if} +
+ + {#if link.description} +

{link.description}

+ {/if} + + {#if data.canManage} +
+ + +
+ + {#if confirmDeleteId === link.id} +
async ({ update }) => { + await update({ reset: false }); + confirmDeleteId = null; + }} + class="mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30" + > + +

Delete "{link.title}"?

+
+ + +
+
+ {/if} + + {#if editingId === link.id} +
async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') editingId = null; + }} + class="mt-2 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50" + > + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + {/if} +
+ {/each} +
+ {/if} + {:else} + +
+

Personal bookmarks are coming soon.

+
+ {/if} +