Skip to content
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Used for Learning Unit podcast creation.
APP_URL=http://localhost:5173

# The connection string to Valkey.
VALKEY_URL=xxx

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "user_admins" (
"id" UUID NOT NULL DEFAULT uuid_generate_v7(),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" VARCHAR(255) NOT NULL,
"email" TEXT NOT NULL,
"google_provider_id" TEXT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,

CONSTRAINT "user_admins_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "user_admins_email_key" ON "user_admins"("email");

-- CreateIndex
CREATE UNIQUE INDEX "user_admins_google_provider_id_key" ON "user_admins"("google_provider_id");
14 changes: 14 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,17 @@ model QuestionAnswer {

@@map("question_answers")
}

model UserAdmin {
id String @id @default(dbgenerated("uuid_generate_v7()")) @map("id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)

// Domain-Specific Fields.
name String @map("name") @db.VarChar(255)
email String @unique @map("email")
googleProviderId String @unique @map("google_provider_id")
isActive Boolean @default(true) @map("is_active")

@@map("user_admins")
}
59 changes: 13 additions & 46 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,20 @@
import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';

import { HOME_PATH, nanoid } from '$lib/helpers/index.js';
import auth from '$lib/server/auth/index.js';
import { logger } from '$lib/server/logger.js';
const route_specific_hooks = import.meta.glob('./routes/**/hooks.server.ts') as Record<
string,
() => Promise<{ handle: Handle }>
>;

/**
* A handle that adds a request ID to the response headers and attaches a scoped logger to the
* event. Downstream handles are expected to use the scoped logger for logging.
*/
const requestLoggingHandle: Handle = async ({ event, resolve }) => {
const requestId = nanoid();
export const handle: Handle = async function handle({ event, resolve }) {
const hookDir = event.url.pathname.startsWith('/admin') ? '/admin' : '/(main)';
const importer = route_specific_hooks['./routes' + hookDir + '/hooks.server.ts'];

event.setHeaders({ 'X-Request-Id': requestId });
event.locals.logger = logger.child({ requestId });

return await resolve(event);
};

/**
* A handle that enforces authentication on protected routes.
* - Requests to `/api/*` are always allowed through
* - Authenticated users visiting `/login` are redirected back to `/`
* - Unauthenticated users are redirected to `/login`
*/
const routeProtectionHandle: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/api/')) {
return await resolve(event);
}

if (event.locals.session.isAuthenticated) {
if (event.url.pathname === '/login') {
return redirect(302, HOME_PATH);
if (importer) {
const module = await importer();
if (module.handle) {
return module.handle({ event, resolve });
}

return await resolve(event);
}

if (
event.url.pathname === '/login' ||
event.url.pathname === '/auth/google' ||
event.url.pathname === '/auth/google/callback' ||
event.url.pathname === '/terms' ||
event.url.pathname === '/privacy'
) {
return await resolve(event);
}

return redirect(303, `/login?return_to=${encodeURIComponent(event.url.pathname)}`);
return resolve(event);
};

export const handle: Handle = sequence(requestLoggingHandle, auth.handle, routeProtectionHandle);
99 changes: 99 additions & 0 deletions src/lib/components/AddableField/AddableField.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script lang="ts" generics="T">
import { Plus, Trash } from '@lucide/svelte';
import type { Snippet } from 'svelte';

import { ErrorMessage } from '$lib/components/ErrorMessage';

interface Props {
/**
* The title for the repeatable field section.
*/
title: string;
/**
* The items to render.
*/
items: T[];
/**
* A callback to add a new item.
*/
onadd: () => void;
/**
* A callback to remove an item.
*/
onremove: (index: number) => void;
/**
* The text for the add button.
*/
addButtonText?: string;
/**
* The empty state message.
*/
emptyMessage?: string;
/**
* The error message to display for the grouped fields.
*/
error?: string;
/**
* The errors for each item.
*/
itemErrors?: Record<string, string>[];
/**
* Snippet to render each item - receives item, index, and item errors
*/
children: Snippet<[T, number, Record<string, string> | undefined]>;
}

const {
title,
items,
onadd,
onremove,
addButtonText = 'Add Item',
emptyMessage = 'No items added yet',
error,
itemErrors,
children,
}: Props = $props();
</script>

<div class="flex flex-col rounded-md border border-slate-100 bg-slate-50 p-4 shadow-md">
<div class="flex items-center justify-between p-2">
{title}
<button
type="button"
onclick={onadd}
class="rounded-md border border-slate-200 bg-white px-3 py-1 text-sm font-medium shadow-md hover:border-slate-200 hover:bg-slate-100 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-950 focus-visible:outline-dashed"
>
<span class="inline-flex items-center gap-1"><Plus class="size-4" />{addButtonText}</span>
</button>
</div>

{#if items.length === 0}
<span class="text-sm text-slate-500">{emptyMessage}</span>
{:else}
{#each items as item, index (index)}
<div class="flex flex-row p-2">
<div
class="flex flex-1 flex-col gap-2 rounded-md border border-slate-200 bg-white p-4 shadow-md"
>
{@render children(item, index, itemErrors?.[index])}
</div>

<button
type="button"
aria-label={`Remove item ${index}`}
onclick={() => onremove(index)}
class="items-center rounded-md px-2 py-1 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-950 focus-visible:outline-dashed"
>
<Trash
class="size-5 text-slate-500 transition-transform delay-100 duration-300 ease-in-out hover:scale-120"
/>
</button>
</div>
{/each}
{/if}

<div class="flex justify-center">
<ErrorMessage message={error} />
</div>
</div>
172 changes: 172 additions & 0 deletions src/lib/components/AddableField/AddableField.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { createRawSnippet } from 'svelte';
import { describe, expect, it, vi } from 'vitest';

import AddableField from './AddableField.svelte';

describe('AddableField', () => {
const children = createRawSnippet<
[{ id: number; name: string }, number, Record<string, string> | undefined]
>((getItem, getIndex) => {
const item = getItem();
const index = getIndex();
return {
render: () => `<input type="text" value="${item.name}" data-testid="item-${index}" />`,
};
});

it('renders the title', () => {
render(AddableField<{ id: number; name: string }>, {
props: {
title: 'My Items',
items: [],
onadd: vi.fn(),
onremove: vi.fn(),
children,
},
});

expect(screen.getByText('My Items')).toBeInTheDocument();
});

it('renders add button', () => {
render(AddableField<{ id: number; name: string }>, {
props: {
title: 'Items',
items: [],
onadd: vi.fn(),
onremove: vi.fn(),
addButtonText: 'Add New Source',
children,
},
});

expect(screen.getByRole('button', { name: 'Add New Source' })).toBeInTheDocument();
});

it('shows empty message when no items', () => {
render(AddableField<{ id: number; name: string }>, {
props: {
title: 'Items',
items: [],
onadd: vi.fn(),
onremove: vi.fn(),
emptyMessage: 'No sources available',
children,
},
});

expect(screen.getByText('No sources available')).toBeInTheDocument();
});

it('renders items when provided', () => {
const mockItems = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];

render(AddableField<{ id: number; name: string }>, {
props: {
title: 'Items',
items: mockItems,
onadd: vi.fn(),
onremove: vi.fn(),
children,
},
});

mockItems.forEach((_, idx) => {
expect(screen.getByTestId(`item-${idx}`)).toBeInTheDocument();
});
});

it('calls onadd when add button is clicked', async () => {
const onadd = vi.fn();

render(AddableField<{ id: number; name: string }>, {
props: {
title: 'Items',
items: [],
onadd,
onremove: vi.fn(),
children,
},
});
await userEvent.click(screen.getByRole('button', { name: 'Add Item' }));

expect(onadd).toHaveBeenCalledTimes(1);
});

it('calls onremove with correct index when remove button is clicked', async () => {
const mockItems = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];
const onremove = vi.fn();

render(AddableField<{ id: number; name: string }>, {
props: {
title: 'Items',
items: mockItems,
onadd: vi.fn(),
onremove,
children,
},
});

const removeButton = screen.getByRole('button', { name: 'Remove item 1' });
await userEvent.click(removeButton);

expect(onremove).toHaveBeenCalledWith(1);
});

it('renders error message', () => {
render(AddableField<{ id: number; name: string }>, {
props: {
title: 'Items',
items: [],
onadd: vi.fn(),
onremove: vi.fn(),
error: 'At least one item is required',
children,
},
});

expect(screen.getByText('At least one item is required')).toBeInTheDocument();
});

it('passes item errors to children snippet', () => {
const mockItems = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
];

const itemErrors: Record<string, string>[] = [
{ name: 'Name is required' },
{ url: 'Invalid URL' },
];

const childrenWithErrors = createRawSnippet<
[{ id: number; name: string }, number, Record<string, string> | undefined]
>((getItem, getIndex, getErrors) => {
const errors = getErrors();
return {
render: () => (errors?.name ? `<span>${errors.name}</span>` : '<span>No errors</span>'),
};
});

render(AddableField<{ id: number; name: string }>, {
props: {
title: 'Items',
items: mockItems,
onadd: vi.fn(),
onremove: vi.fn(),
itemErrors,
children: childrenWithErrors,
},
});

expect(screen.getByText('Name is required')).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/lib/components/AddableField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as AddableField } from './AddableField.svelte';
Loading