34 Commits

Author SHA1 Message Date
schreifuchs 9fac4d0149 Merge pull request 'Fix Action Validation Error Handling' (#14) from issue-3 into main
Commit / ci (push) Waiting to run
Reviewed-on: #14
2026-04-03 17:27:28 +02:00
schreifuchs c71a28fd98 Merge pull request 'Fix Hacky Fallback in Auth Query' (#15) from issue-4 into main
Commit / ci (push) Waiting to run
Reviewed-on: #15
2026-04-03 17:27:09 +02:00
schreifuchs 3bced84749 Merge pull request 'Extend Auth.js Types Globally' (#20) from issue-7 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #20
2026-04-03 17:26:07 +02:00
schreifuchs 2c47870e0f revert: Docker base image to original
Commit / ci (push) Successful in 10m26s
PullRequest / publish (pull_request) Failing after 2m27s
2026-04-03 14:28:08 +02:00
schreifuchs cd6ba6df9a revert: Docker base image to original
Commit / ci (push) Successful in 10m35s
PullRequest / publish (pull_request) Failing after 2m12s
2026-04-03 14:27:41 +02:00
schreifuchs 6d86630b4a revert: Docker base image to original
Commit / ci (push) Successful in 10m24s
PullRequest / publish (pull_request) Failing after 2m15s
2026-04-03 14:27:24 +02:00
schreifuchs 8558e88a71 revert: Docker base image change on main
Commit / ci (push) Successful in 10m40s
2026-04-03 14:27:20 +02:00
schreifuchs 8c192ce8ab Merge pull request 'Clean up Redundant Imports' (#22) from issue-11 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #22
2026-04-03 14:09:40 +02:00
schreifuchs b064ccf5d6 Merge pull request 'Add Vitest + Svelte Testing Library' (#23) from issue-13 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #23
2026-04-03 14:09:29 +02:00
schreifuchs fdb8017087 ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Successful in 10m27s
PullRequest / publish (pull_request) Successful in 5m0s
2026-04-03 13:58:05 +02:00
schreifuchs 641524218e ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Successful in 4m8s
2026-04-03 13:57:44 +02:00
schreifuchs af02de06b1 ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Successful in 4m14s
2026-04-03 13:57:24 +02:00
schreifuchs 15829e1b19 ci: fix docker hub rate limit with ecr mirror
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Successful in 5m6s
2026-04-03 13:57:14 +02:00
schreifuchs 9a49b9a29a refactor: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m29s
2026-04-03 13:54:52 +02:00
schreifuchs d839e9f178 chore: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m28s
2026-04-03 13:54:26 +02:00
schreifuchs 52ecbac1bd chore: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m13s
2026-04-03 13:54:04 +02:00
schreifuchs 97e11a4de7 refactor: resolve merge conflicts
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m28s
2026-04-03 13:53:43 +02:00
schreifuchs d6f6125204 ci: run tests in pipeline and fix docker rate limits
Commit / ci (push) Successful in 10m32s
PullRequest / publish (pull_request) Failing after 5m21s
2026-04-03 13:53:21 +02:00
schreifuchs beb790bed8 chore: resolve merge conflicts 2026-04-03 13:52:58 +02:00
schreifuchs c1a0a5de6c Merge pull request 'Abstract Heavy Database Queries' (#24) from issue-10 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #24
2026-04-03 13:48:53 +02:00
schreifuchs 2140d06fb5 Merge pull request 'Parallelize Database Queries' (#19) from issue-5 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #19
2026-04-03 13:42:19 +02:00
schreifuchs 6e24b68a08 Merge pull request 'Move Server-Only Code to $lib/server' (#16) from issue-2 into main
Commit / ci (push) Has been cancelled
Reviewed-on: #16
2026-04-03 13:40:06 +02:00
schreifuchs b3087aa9d4 test: add vitest and svelte testing library (resolves #13)
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 1m56s
2026-04-03 13:26:59 +02:00
schreifuchs c459d58a28 refactor: abstract heavy database queries (resolves #10)
Commit / ci (push) Successful in 10m34s
PullRequest / publish (pull_request) Failing after 2m17s
2026-04-03 13:26:55 +02:00
schreifuchs 005dc22a2e refactor: clean up redundant imports (resolves #11)
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m22s
2026-04-03 13:26:04 +02:00
schreifuchs 239bf163e8 fix: XSS Vulnerability (#17)
Commit / ci (push) Has been cancelled
Resolves #1

Reviewed-on: #17
2026-04-03 13:09:45 +02:00
schreifuchs 8483ab9e84 refactor: extend auth.js types globally (resolves #7)
Commit / ci (push) Has been cancelled
PullRequest / publish (pull_request) Failing after 2m41s
2026-04-03 13:06:47 +02:00
schreifuchs 16248416e7 perf: parallelize database queries (resolves #5)
Commit / ci (push) Successful in 10m40s
PullRequest / publish (pull_request) Failing after 2m23s
2026-04-03 13:06:33 +02:00
schreifuchs 7d9ff9ff2b refactor: move server-only code (resolves #2)
PullRequest / publish (pull_request) Failing after 2m22s
Commit / ci (push) Successful in 10m38s
2026-04-03 13:01:30 +02:00
schreifuchs 7492680457 fix: validation error handling (resolves #3)
Commit / ci (push) Successful in 10m34s
PullRequest / publish (pull_request) Failing after 2m36s
2026-04-03 13:00:14 +02:00
schreifuchs 85edb99e64 fix: auth query fallback (resolves #4)
Commit / ci (push) Successful in 10m29s
PullRequest / publish (pull_request) Failing after 2m28s
2026-04-03 13:00:11 +02:00
schreifuchs 2e16cf9d51 docs: add TODO.md with project review and refactoring tasks
Commit / ci (push) Successful in 10m32s
2026-04-03 12:31:11 +02:00
schreifuchs 2702615b34 feat: added comments
Commit / ci (push) Successful in 1m37s
Release / publish (push) Failing after 5m57s
2025-12-10 19:16:18 +01:00
schreifuchs 5b8a436b91 fix: update akti
Commit / ci (push) Successful in 1m27s
Release / publish (push) Successful in 3m55s
2025-12-08 12:10:40 +01:00
24 changed files with 1652 additions and 46 deletions
+3
View File
@@ -34,3 +34,6 @@ jobs:
- name: Type Check (Svelte Check) - name: Type Check (Svelte Check)
# Based on your package.json "check" script # Based on your package.json "check" script
run: pnpm check run: pnpm check
- name: Run Tests (Vitest)
run: pnpm run test
+65
View File
@@ -0,0 +1,65 @@
# Project Review & Refactoring TODOs
This document contains the prioritized list of refactoring tasks, architectural improvements, and testing strategies for the Aktiteil project.
## 🚨 Must do (Security & Critical Best Practices)
- [ ] **Fix Critical XSS Vulnerability (`{@html}` without sanitization)**
- **Where:** `src/routes/akti/[aktiId]/+page.svelte`
- **Why:** Rendering user input via `{@html data.akti.body}` without sanitization allows malicious scripts to be injected.
- **Fix:** Use the already installed `sanitize-html` library on the server to sanitize `changeRequest.body` before updating/inserting into the database.
- [ ] **Move Server-Only Code to `$lib/server`**
- **Where:** `src/lib/auth.ts`
- **Why:** It imports from `./server/db`. Keeping server-side dependencies in the general `$lib` folder risks accidental imports by client components, breaking the Vite build and potentially leaking server logic.
- **Fix:** Move and rename it to `src/lib/server/session.ts` (or `authUtils.ts`) and update imports in `.server.ts` files.
- [ ] **Fix Action Validation Error Handling**
- **Where:** `src/routes/akti/[aktiId]/+page.server.ts` and `src/routes/akti/[aktiId]/comment/+page.server.ts`
- **Why:** Currently returning `error(400)` on validation failure, which wipes form data and shows a generic error page.
- **Fix:** Use SvelteKit's `fail(400, { message: 'Invalid data' })` to keep the user on the page and preserve their input.
- [ ] **Fix Hacky Fallback in Auth Query**
- **Where:** `src/lib/auth.ts` -> `getSession()`
- **Why:** Querying the DB with a fallback UUID (`eaf930...`) when email is missing is an anti-pattern.
- **Fix:** Implement an early return (`if (!session?.user?.email) return null;`) before hitting the database.
## 🛠️ Should do (Performance & Architecture)
- [ ] **Parallelize Database Queries**
- **Where:** `src/routes/akti/[aktiId]/+page.server.ts` (load function)
- **Why:** Queries are running sequentially.
- **Fix:** Use `Promise.all([ db.query.aktis.findFirst(...), db.query.ratings.findMany(...) ])` to run concurrently.
- [ ] **Implement Pagination / Limit for the Dashboard**
- **Where:** `src/routes/+page.server.ts`
- **Why:** Querying all records joined with ratings will scale poorly.
- **Fix:** Add a `.limit()` clause and consider basic pagination or infinite scrolling.
- [ ] **Extend Auth.js Types Globally**
- **Where:** `src/app.d.ts`
- **Why:** TypeScript doesn't inherently know `session.user.id` exists, leading to hacky workarounds.
- **Fix:** Override `@auth/sveltekit` Session types in `app.d.ts` to include `id` and `email` strictly.
- [ ] **Consider Adopting a Form Library**
- **Where:** `src/lib/extractFormData.ts`
- **Why:** Custom form extractors lack instant client-side validation and seamless server-side error mapping.
- **Fix:** Consider switching to `sveltekit-superforms` which integrates well with Valibot.
## ✨ Nice to have (UX & Polish)
- [ ] **Clarify File Naming (`auth.ts` vs `auth.ts`)**
- Rename `src/lib/auth.ts` to `session.ts` or similar to distinguish from `src/auth.ts` (Auth.js setup).
- [ ] **Abstract Heavy Database Queries**
- Move complex aggregations (like computing averages in `src/routes/+page.server.ts`) into a dedicated `src/lib/server/db/queries.ts` file to keep routes clean.
- [ ] **Clean up Redundant Imports**
- In `src/routes/+layout.server.ts`, change `import { getSession as getSession }` to `import { getSession }`.
## 🧪 Testing Plan
- [ ] **Add Playwright (End-to-End Testing)**
- Install Playwright to test SvelteKit server actions, DB integration, and Flowbite forms holistically.
- [ ] **Add Vitest + Svelte Testing Library (Unit/Component Testing)**
- Set up Vitest to test UI components (`AktiCard`, `AktiEditor`) and utility functions (`extractFormData`) in isolation.
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "rating" ALTER COLUMN "comment" SET NOT NULL;
+436
View File
@@ -0,0 +1,436 @@
{
"id": "0528e286-2cd4-4447-a3a4-5a7c2cda83b9",
"prevId": "8f81af0f-3a4e-4fb8-b7f1-cf4e6eec7ecb",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.authenticator": {
"name": "authenticator",
"schema": "",
"columns": {
"credentialID": {
"name": "credentialID",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialPublicKey": {
"name": "credentialPublicKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"credentialDeviceType": {
"name": "credentialDeviceType",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialBackedUp": {
"name": "credentialBackedUp",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"authenticator_userId_user_id_fk": {
"name": "authenticator_userId_user_id_fk",
"tableFrom": "authenticator",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"authenticator_credentialID_unique": {
"name": "authenticator_credentialID_unique",
"nullsNotDistinct": false,
"columns": ["credentialID"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verificationToken": {
"name": "verificationToken",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.akti": {
"name": "akti",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"akti_user_id_user_id_fk": {
"name": "akti_user_id_user_id_fk",
"tableFrom": "akti",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.rating": {
"name": "rating",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"akti_id": {
"name": "akti_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rating": {
"name": "rating",
"type": "real",
"primaryKey": false,
"notNull": true
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true
},
"akti_version": {
"name": "akti_version",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"rating_akti_id_akti_id_fk": {
"name": "rating_akti_id_akti_id_fk",
"tableFrom": "rating",
"tableTo": "akti",
"columnsFrom": ["akti_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"rating_user_id_user_id_fk": {
"name": "rating_user_id_user_id_fk",
"tableFrom": "rating",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -29,6 +29,13 @@
"when": 1764000151184, "when": 1764000151184,
"tag": "0003_tranquil_iron_monger", "tag": "0003_tranquil_iron_monger",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1765385786051,
"tag": "0004_wealthy_groot",
"breakpoints": true
} }
] ]
} }
+8 -1
View File
@@ -10,6 +10,8 @@
"prepare": "husky", "prepare": "husky",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"db:start": "docker compose up", "db:start": "docker compose up",
@@ -28,8 +30,11 @@
"@sveltejs/kit": "^2.49.0", "@sveltejs/kit": "^2.49.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@tiptap/core": "3.7.2", "@tiptap/core": "3.7.2",
"@types/node": "^20.19.25", "@types/node": "^20.19.25",
"@types/sanitize-html": "^2.16.1",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -39,6 +44,7 @@
"flowbite-svelte-icons": "^3.0.0", "flowbite-svelte-icons": "^3.0.0",
"globals": "^16.5.0", "globals": "^16.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.0.1",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
@@ -47,7 +53,8 @@
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^7.2.4" "vite": "^7.2.4",
"vitest": "^4.1.2"
}, },
"dependencies": { "dependencies": {
"@auth/drizzle-adapter": "^1.11.1", "@auth/drizzle-adapter": "^1.11.1",
+787
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,3 +1,5 @@
import { DefaultSession } from '@auth/sveltekit';
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
@@ -10,4 +12,13 @@ declare global {
} }
} }
declare module '@auth/sveltekit' {
interface Session {
user: {
id: string;
email: string;
} & DefaultSession['user'];
}
}
export {}; export {};
@@ -0,0 +1,31 @@
<script lang="ts">
import { Rating as FbRating } from 'flowbite-svelte';
import UserDisplay from '../UserDisplay.svelte';
interface Rating {
rating: number;
comment: string;
outdated?: boolean;
user?: {
name: string | null;
image: string | null;
};
}
let { rating }: { rating: Rating } = $props();
</script>
<div
class="w-full bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 shadow-md flex flex-col items-start p-5 gap-5"
>
{#if rating.user}
<UserDisplay user={rating.user} />
{/if}
{#if rating.outdated}
<span class="self-end">i</span>
{/if}
<p class="text-left">{rating.comment}</p>
{#if rating.rating}
<FbRating id="example-1b" total={5} size={30} rating={rating.rating} class="self-end mt-auto" />
{/if}
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Button, Label, Textarea } from 'flowbite-svelte';
import RatingInput from './RatingInput.svelte';
interface Rating {
rating: number;
comment: string;
}
let { rating }: { rating: Rating } = $props();
</script>
<form method="POST" class="flex flex-col gap-5">
<div>
<Label>Komentar</Label>
<Textarea
value={rating.comment}
name="comment"
required
minlength={5}
class="w-full min-h-40"
/>
</div>
<div>
<Label>Bewärtig</Label>
<RatingInput value={rating.rating} name="rating" />
</div>
<Button type="submit" class="grow-0 self-end">Spichere</Button>
</form>
@@ -0,0 +1,63 @@
<script lang="ts">
import { StarHalfSolid, StarSolid } from 'flowbite-svelte-icons';
import { twMerge, type ClassNameValue } from 'tailwind-merge';
let {
value = $bindable(5),
name,
disabled = false,
class: className
}: { value?: number; name?: string; disabled?: boolean; class?: ClassNameValue } = $props();
let stars = $derived(
[0, 1, 2, 3, 4].map((i) => {
if (value - i >= 1) {
return { id: i, value: 1 };
} else if (value - i >= 0.5) {
return { id: i, value: 0.5 };
}
return { id: i, value: 0 };
})
);
</script>
{#if name}
<input
type="number"
{name}
bind:value
tabindex="-1"
style="opacity: 0; position: absolute; pointer-events: none; z-index: -1; bottom: 0; left: 50%; height: 0; width: 0;"
/>
{/if}
<span class={twMerge('flex justify-between ', className)}>
<!-- es -->
{#each stars as s (s.id)}
<button
type="button"
{disabled}
onclick={() => {
if (s.id === 0 && value == 0.5) {
value = 0;
return;
}
if (value === s.id + 1) {
value = s.id + 0.5;
return;
}
value = s.id + 1;
}}
class="grid grid-cols-2 grid-rows-1"
>
{#if s.value >= 1}
<StarSolid class="col-span-2 text-amber-400" />
{:else}
<StarSolid class="col-span-2 col-start-1 row-start-1" />
{#if s.value >= 0.5}
<StarHalfSolid class="col-span-1 col-start-1 row-start-1 text-amber-400" />
{/if}
{/if}
</button>
{/each}
</span>
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { extractFormData } from './extractFormData';
import * as v from 'valibot';
describe('extractFormData', () => {
it('should successfully extract and validate correct form data', async () => {
const formData = new FormData();
formData.append('name', 'John Doe');
formData.append('age', '30');
const request = new Request('http://localhost', {
method: 'POST',
body: formData
});
const schema = v.object({
name: v.string(),
age: v.string()
});
const result = await extractFormData(request, schema);
expect(result.error).toBeNull();
expect(result.data).toEqual({ name: 'John Doe', age: '30' });
});
it('should fail validation with missing required fields', async () => {
const formData = new FormData();
formData.append('age', '30');
const request = new Request('http://localhost', {
method: 'POST',
body: formData
});
const schema = v.object({
name: v.string(),
age: v.string()
});
const result = await extractFormData(request, schema);
expect(result.data).toBeUndefined();
expect(result.error).toBeTypeOf('string');
});
});
+16
View File
@@ -0,0 +1,16 @@
import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema';
import { avg, eq } from 'drizzle-orm';
export async function getAktisWithAvgRating() {
return await db
.select({
id: aktis.id,
title: aktis.title,
summary: aktis.summary,
rating: avg(ratings.rating)
})
.from(aktis)
.leftJoin(ratings, eq(aktis.id, ratings.aktiId))
.groupBy(aktis.id, aktis.title, aktis.summary);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const ratings = pgTable('rating', {
.notNull() .notNull()
.references(() => users.id, { onDelete: 'cascade' }), .references(() => users.id, { onDelete: 'cascade' }),
rating: real().notNull(), rating: real().notNull(),
comment: text(), comment: text().notNull(),
aktiVersion: integer('akti_version').notNull() aktiVersion: integer('akti_version').notNull()
}); });
+8 -11
View File
@@ -1,39 +1,36 @@
import type { Session, User } from '@auth/sveltekit'; import type { Session } from '@auth/sveltekit';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { db } from './server/db'; import { db } from './db';
import { users } from './server/db/schema'; import { users } from './db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
interface Event { interface Event {
locals: { locals: {
auth(): Promise<Session | null>; auth(): Promise<Session | null>;
}; };
} }
interface UserWithId extends User {
id: string;
email: string;
}
export async function ensureAuth(event: Event): Promise<UserWithId> { export async function ensureAuth(event: Event): Promise<Session['user']> {
const session = await getSession(event); const session = await getSession(event);
if (!session) error(401, { message: 'Du muesch di zersch iiloge' }); if (!session) error(401, { message: 'Du muesch di zersch iiloge' });
const user = session?.user; const user = session.user;
if (!user || !user.email || !user.id) { if (!user || !user.email || !user.id) {
error(401, { message: 'Du muesch di zersch iiloge' }); error(401, { message: 'Du muesch di zersch iiloge' });
} }
return { ...user, id: user.id, email: user.email }; // weird thingamajig so that ts compiler is happy return user;
} }
export async function getSession(event: Event) { export async function getSession(event: Event) {
const session = await event.locals.auth(); const session = await event.locals.auth();
if (!session) return null; if (!session) return null;
if (!session.user) error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' }); if (!session.user) error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' });
if (!session?.user?.email) return null;
const res = await db const res = await db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .from(users)
.limit(1) .limit(1)
.where(eq(users.email, session.user.email ?? 'eaf9302d-9525-4f3e-8147-9620d2076f67')); //uuid as default to find nothing .where(eq(users.email, session.user.email));
if (!res[0]?.id) { if (!res[0]?.id) {
error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' }); error(403, { message: 'Di gits garnid. Vilich nomau usloge u iiloge?' });
+1 -1
View File
@@ -1,4 +1,4 @@
import { getSession as getSession } from '$lib/auth'; import { getSession } from '$lib/server/session';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => { export const load: LayoutServerLoad = async (event) => {
+2 -13
View File
@@ -1,19 +1,8 @@
import { db } from '$lib/server/db'; import { getAktisWithAvgRating } from '$lib/server/db/queries';
import { aktis, ratings } from '$lib/server/db/schema';
import { avg, eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const a = await db const a = await getAktisWithAvgRating();
.select({
id: aktis.id,
title: aktis.title,
summary: aktis.summary,
rating: avg(ratings.rating)
})
.from(aktis)
.leftJoin(ratings, eq(aktis.id, ratings.aktiId))
.groupBy(aktis.id, aktis.title, aktis.summary);
return { return {
aktis: a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined })) aktis: a.map((a) => ({ ...a, rating: a.rating ? parseFloat(a.rating) : undefined }))
+4 -1
View File
@@ -4,9 +4,10 @@ import { extractFormData } from '$lib/extractFormData';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import * as v from 'valibot'; import * as v from 'valibot';
import { ensureAuth } from '$lib/auth'; import { ensureAuth } from '$lib/server/session';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { aktis } from '$lib/server/db/schema'; import { aktis } from '$lib/server/db/schema';
import sanitizeHtml from 'sanitize-html';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
await ensureAuth(event); await ensureAuth(event);
return {}; return {};
@@ -28,6 +29,8 @@ export const actions = {
if (!akti) return {}; if (!akti) return {};
akti.body = sanitizeHtml(akti.body);
const res = await db const res = await db
.insert(aktis) .insert(aktis)
.values({ ...akti, author: user.id! }) .values({ ...akti, author: user.id! })
+22 -18
View File
@@ -1,23 +1,25 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { aktis, ratings } from '$lib/server/db/schema'; import { aktis, ratings } from '$lib/server/db/schema';
import { error, redirect, type Actions } from '@sveltejs/kit'; import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/auth'; import { ensureAuth } from '$lib/server/session';
import { extractFormData } from '$lib/extractFormData'; import { extractFormData } from '$lib/extractFormData';
import * as v from 'valibot'; import * as v from 'valibot';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import sanitizeHtml from 'sanitize-html';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const akti = await db.query.aktis.findFirst({ const [akti, r] = await Promise.all([
where: eq(aktis.id, event.params.aktiId), db.query.aktis.findFirst({
with: { author: true } where: eq(aktis.id, event.params.aktiId),
}); with: { author: true }
}),
const r = await db.query.ratings.findMany({ db.query.ratings.findMany({
with: { user: true }, with: { user: true },
where: eq(ratings.aktiId, event.params.aktiId) where: eq(ratings.aktiId, event.params.aktiId)
}); })
]);
if (!akti) { if (!akti) {
error(404, { message: 'Die Akti gits garnid, sorry...' }); error(404, { message: 'Die Akti gits garnid, sorry...' });
@@ -54,13 +56,15 @@ export const actions = {
) )
).data; ).data;
if (!changeRequest) return error(400); if (!changeRequest) return fail(400, { message: 'Invalid data' });
const res = await db changeRequest.body = sanitizeHtml(changeRequest.body);
.insert(aktis)
.values({ ...changeRequest, author: user.id, version: akti[0].version + 1 })
.returning({ id: aktis.id });
return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: res[0].id })); await db
.update(aktis)
.set({ ...changeRequest, version: akti[0].version + 1 })
.where(and(eq(aktis.author, user.id), eq(aktis.id, event.params.aktiId)));
return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: event.params.aktiId }));
} }
} satisfies Actions; } satisfies Actions;
+22
View File
@@ -4,10 +4,17 @@
import { EditOutline, CloseOutline } from 'flowbite-svelte-icons'; import { EditOutline, CloseOutline } from 'flowbite-svelte-icons';
import AktiEditor from '$lib/components/akti/AktiEditor.svelte'; import AktiEditor from '$lib/components/akti/AktiEditor.svelte';
import UserDisplay from '$lib/components/UserDisplay.svelte'; import UserDisplay from '$lib/components/UserDisplay.svelte';
import RatingCard from '$lib/components/rating/RatingCard.svelte';
import { resolve } from '$app/paths';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let edit = $state(false); let edit = $state(false);
let canComment = $derived.by(() => {
if (!data.session) return false;
if (data.akti.author.id === data.session.user.id) return false;
return true;
});
</script> </script>
<div class="flex justify-between"> <div class="flex justify-between">
@@ -37,4 +44,19 @@
</div> </div>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html data.akti.body} {@html data.akti.body}
<div class="mt-10 mb-5 flex justify-between items-center">
<h3>Kommentär</h3>
{#if canComment}
<Button href={resolve('/akti/[aktiId]/comment', { aktiId: data.akti.id })}>
Ä Kommentar da la
</Button>
{/if}
</div>
<section class="grid grid-cols-1 gap-5 2xl:grid-cols-3">
{#each data.ratings as rating (rating.id)}
<RatingCard {rating} />
{/each}
</section>
{/if} {/if}
@@ -0,0 +1,68 @@
import type { PageServerLoad } from './$types';
import { ensureAuth } from '$lib/server/session';
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { extractFormData } from '$lib/extractFormData';
import { aktis, ratings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import * as v from 'valibot';
import { db } from '$lib/server/db';
import { resolve } from '$app/paths';
export const load: PageServerLoad = async (event) => {
const user = await ensureAuth(event);
const res = await db
.select({ authorId: aktis.author })
.from(aktis)
.where(eq(aktis.id, event.params.aktiId));
if (!res[0]) return error(404);
if (res[0].authorId === user.id) return error(403);
return;
};
export const actions = {
default: async (event) => {
const user = await ensureAuth(event);
if (!event.params.aktiId) return error(404);
const akti = await db
.select({ id: aktis.id, version: aktis.version, author: aktis.author })
.from(aktis)
.limit(1)
.where(eq(aktis.id, event.params.aktiId));
if (!akti || akti.length == 0) return error(404);
if (akti[0].author == user.id) return error(403);
const rating = (
await extractFormData(
event.request,
v.object({
comment: v.pipe(v.string(), v.minLength(5)),
rating: v.pipe(
v.string(),
v.transform((i) => Number.parseFloat(i)),
v.minValue(0),
v.maxValue(5)
)
})
)
).data;
if (!rating) return fail(400, { message: 'Invalid data' });
await db.insert(ratings).values({
...rating,
userId: user.id,
aktiId: event.params.aktiId,
aktiVersion: akti[0].version
});
return redirect(303, resolve(`/akti/[aktiId]`, { aktiId: event.params.aktiId }));
}
} satisfies Actions;
@@ -0,0 +1,10 @@
<script lang="ts">
import RatingEditor from '$lib/components/rating/RatingEditor.svelte';
</script>
<RatingEditor
rating={{
comment: '',
rating: 5
}}
/>
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
export default defineConfig({
plugins: [sveltekit(), svelteTesting()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
setupFiles: ['./vitest-setup.ts']
}
});