This commit is contained in:
Bastian Wagner
2026-06-09 09:45:33 +02:00
commit 537c7cbbee
124 changed files with 27283 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
**/.git
**/.angular
**/.cache
**/coverage
**/dist
**/node_modules
**/.env
**/.env.*
npm-debug.log
Dockerfile
docker-compose*.yml

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM node:22-alpine AS api-deps
WORKDIR /app
COPY listify-api/package*.json ./
RUN npm ci --omit=dev
FROM node:22-alpine AS api-builder
WORKDIR /app
COPY listify-api/package*.json ./
RUN npm ci
COPY listify-api/ ./
RUN npm run build
FROM node:22-alpine AS web-builder
WORKDIR /app
COPY listify-client/package*.json ./
RUN npm ci
COPY listify-client/ ./
RUN npm run build
FROM node:22-alpine AS runtime
RUN apk add --no-cache nginx
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=api-deps /app/node_modules ./node_modules
COPY --from=api-builder /app/dist ./dist
COPY --from=web-builder /app/dist/listify-client/browser /usr/share/nginx/html
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/start.sh /usr/local/bin/start-listify
RUN chmod +x /usr/local/bin/start-listify
EXPOSE 80
CMD ["/usr/local/bin/start-listify"]

9
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
listify:
image: listify:local
build:
context: .
env_file:
- ./listify-api/.env
ports:
- "8080:80"

31
docker/nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /api {
return 301 /api/;
}
location /api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location / {
try_files $uri $uri/ /index.html;
}
}

24
docker/start.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
node /app/dist/main.js &
api_pid="$!"
nginx_pid=""
shutdown() {
kill "$api_pid" 2>/dev/null || true
kill "$nginx_pid" 2>/dev/null || true
}
trap shutdown TERM INT
nginx -g "daemon off;" &
nginx_pid="$!"
while kill -0 "$api_pid" 2>/dev/null && kill -0 "$nginx_pid" 2>/dev/null; do
sleep 1
done
shutdown
wait "$api_pid" 2>/dev/null || true
wait "$nginx_pid" 2>/dev/null || true

View File

@@ -0,0 +1,16 @@
PORT=3000
# Wenn MySQL auf deinem Windows-/Host-System laeuft, nutze mit Docker Desktop:
# DB_HOST=host.docker.internal
DB_HOST=host.docker.internal
DB_PORT=3306
DB_USERNAME=listify
DB_PASSWORD=change-me
DB_DATABASE=listify
DB_LOGGING=false
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
# Browser-URL, unter der der Container erreichbar ist.
CLIENT_URL=http://localhost:8080

13
listify-api/.env.example Normal file
View File

@@ -0,0 +1,13 @@
PORT=3000
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=listify
DB_PASSWORD=change-me
DB_DATABASE=listify
DB_LOGGING=false
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
CLIENT_URL=http://localhost:4200

56
listify-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
listify-api/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
listify-api/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

80
listify-api/ROADMAP.md Normal file
View File

@@ -0,0 +1,80 @@
# Listify API Roadmap
This roadmap captures the next practical milestones for the Listify API. The project is currently a NestJS service with registration, email verification, login, refresh-token rotation, a JWT guard, event-based mail handling, Helmet, and focused auth tests. Runtime state is still in memory, mail delivery is logged/stubbed, and the README is still the default NestJS starter text.
## Guiding Goals
- Build a reliable API for user accounts and list management.
- Move from prototype in-memory state to durable, observable production services.
- Keep security and test coverage ahead of user-facing feature expansion.
- Document setup, environment variables, and release workflows clearly enough for repeatable development.
## Phase 1: Foundation Hardening
- Replace in-memory user and refresh-token maps with a database-backed persistence layer.
- Choose and configure the data stack, including migrations, local development setup, and test database strategy.
- Move configuration into a typed config module with explicit validation for JWT secrets, token lifetimes, base URLs, mail settings, and port.
- Replace the hard-coded verification URL with environment-aware URL generation.
- Add validation pipes globally so DTO validation is enforced consistently at API boundaries.
- Add CORS policy configuration for the intended frontend origins.
- Update the README with real Listify setup, scripts, environment variables, and testing instructions.
## Phase 2: Authentication And Account Management
- Persist users, email verification tokens, refresh tokens, and token revocation metadata.
- Add logout and logout-all-devices endpoints.
- Add password reset request and password reset confirmation flows.
- Add resend verification email support with rate limiting.
- Strengthen password policy and add account lockout or throttling for repeated login failures.
- Add authenticated profile endpoints for viewing and updating basic account details.
- Add tests for persistence behavior, token expiry paths, logout, reset flows, and guard-protected routes.
## Phase 3: Listify Product API
- Define the core domain model for lists, list items, ownership, ordering, completion state, and timestamps.
- Add CRUD endpoints for lists and list items.
- Enforce authorization so users can only access their own lists unless explicit sharing is introduced.
- Support item reordering, bulk completion, archive/delete flows, and basic filtering.
- Add pagination and query limits where list or item collections can grow.
- Add OpenAPI documentation for auth and list endpoints.
- Expand e2e coverage across the main account and list workflows.
## Phase 4: Mail And Event Reliability
- Replace the stub mail service with a real provider integration behind the existing mail service boundary.
- Add HTML/text templates for verification and password reset emails.
- Add provider error handling, retries, and structured logging for mail delivery failures.
- Persist outbound email intent or event processing state if reliable delivery becomes required.
- Add tests around event listeners and mail provider adapters.
## Phase 5: Platform Readiness
- Add structured request logging with correlation IDs.
- Add health and readiness endpoints for deployment checks.
- Add rate limiting for auth and high-volume endpoints.
- Add CI for linting, tests, coverage, and build verification.
- Add Docker or equivalent deployment packaging if the target runtime needs it.
- Add production environment documentation, secret management guidance, and rollback notes.
- Add dependency update and security audit routines.
## Phase 6: Collaboration And Sharing
- Introduce list sharing or team/workspace concepts if required by the product direction.
- Add invitation flows, roles, and permissions for shared lists.
- Add audit-friendly activity events for important list and account changes.
- Consider real-time list updates once the core REST API is stable.
## Open Decisions
- Database choice and migration tooling.
- Mail provider and whether delivery must be durable/retryable.
- Frontend base URL and allowed production origins.
- API versioning strategy.
- Whether shared lists are part of the initial product scope or a later collaboration feature.
## Suggested Immediate Next Steps
1. Select the database and migration approach.
2. Add a typed configuration module and global validation pipe.
3. Replace the default NestJS README with project-specific documentation.
4. Implement persistent auth storage before expanding the list domain.

View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10864
listify-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

84
listify-api/package.json Normal file
View File

@@ -0,0 +1,84 @@
{
"name": "listify-api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
"migration:generate": "npm run typeorm -- migration:generate src/database/migrations/GeneratedMigration",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.1.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.1",
"helmet": "^8.2.0",
"mysql2": "^3.22.5",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.30"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { ListTemplatesModule } from './list-templates/list-templates.module';
import { ListsModule } from './lists/lists.module';
import { MailModule } from './mail/mail.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get<string>('DB_HOST', 'localhost'),
port: Number(configService.get<string>('DB_PORT', '3306')),
username: configService.get<string>('DB_USERNAME', 'root'),
password: configService.get<string>('DB_PASSWORD', ''),
database: configService.get<string>('DB_DATABASE', 'listify'),
autoLoadEntities: true,
synchronize: false,
logging: configService.get<string>('DB_LOGGING', 'false') === 'true',
}),
}),
EventEmitterModule.forRoot(),
AuthModule,
MailModule,
ListsModule,
ListTemplatesModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {
constructor(configService: ConfigService) {
console.log(configService.get<string>('DB_HOST', 'localhost'))
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -0,0 +1,39 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
@Get('verify-email')
verifyEmail(@Query('token') token?: string) {
return this.authService.verifyEmail(token);
}
@Post('login')
@HttpCode(HttpStatus.OK)
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('refresh')
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refresh(refreshTokenDto);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { RefreshTokenEntity } from './refresh-token.entity';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { UserEntity } from './user.entity';
@Module({
imports: [JwtModule.register({}), TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity])],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,194 @@
import { EventEmitterModule } from '@nestjs/event-emitter';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { JwtTokenPayload } from './auth.types';
import { AuthService } from './auth.service';
import { MailModule } from '../mail/mail.module';
import { MailService } from '../mail/mail.service';
import { RefreshTokenEntity } from './refresh-token.entity';
import { UserEntity } from './user.entity';
import { InMemoryRepository } from '../testing/in-memory-repository';
describe('AuthService', () => {
let module: TestingModule;
let authService: AuthService;
let mailService: MailService;
let jwtService: JwtService;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule],
providers: [
AuthService,
{
provide: getRepositoryToken(UserEntity),
useValue: new InMemoryRepository<UserEntity>(),
},
{
provide: getRepositoryToken(RefreshTokenEntity),
useValue: new InMemoryRepository<RefreshTokenEntity>(),
},
],
}).compile();
await module.init();
authService = module.get<AuthService>(AuthService);
mailService = module.get<MailService>(MailService);
jwtService = module.get<JwtService>(JwtService);
});
afterEach(async () => {
await module.close();
});
it('registers a user and sends a verification email', async () => {
const response = await authService.register({
email: 'User@Example.com',
name: 'Test User',
password: 'password123',
});
const sentEmails = mailService.getSentEmails();
expect(response.user.email).toBe('user@example.com');
expect(response.user.verified).toBe(false);
expect(sentEmails).toHaveLength(1);
expect(sentEmails[0].to).toBe('user@example.com');
expect(sentEmails[0].verificationUrl).toContain('/verify-email?token=');
});
it('rejects login before email verification', async () => {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
await expect(
authService.login({
email: 'user@example.com',
password: 'password123',
}),
).rejects.toThrow('Please verify your email before login.');
});
it('verifies email and allows login afterwards', async () => {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
const verifyResponse = await authService.verifyEmail(token ?? undefined);
const loginResponse = await authService.login({
email: 'user@example.com',
password: 'password123',
});
const accessPayload = jwtService.verify<JwtTokenPayload>(
loginResponse.accessToken,
{
secret: process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret',
},
);
const refreshPayload = jwtService.verify<JwtTokenPayload>(
loginResponse.refreshToken,
{
secret: process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret',
},
);
expect(verifyResponse.user.verified).toBe(true);
expect(loginResponse.accessToken).toBeDefined();
expect(loginResponse.refreshToken).toBeDefined();
expect(loginResponse.user.email).toBe('user@example.com');
expect(accessPayload.type).toBe('access');
expect(accessPayload.sub).toBe(loginResponse.user.id);
expect(refreshPayload.type).toBe('refresh');
expect(refreshPayload.jti).toBeDefined();
});
it('rotates refresh tokens and rejects reuse', async () => {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
await authService.verifyEmail(token ?? undefined);
const loginResponse = await authService.login({
email: 'user@example.com',
password: 'password123',
});
const refreshResponse = await authService.refresh({
refreshToken: loginResponse.refreshToken,
});
expect(refreshResponse.accessToken).toBeDefined();
expect(refreshResponse.refreshToken).toBeDefined();
expect(refreshResponse.refreshToken).not.toBe(loginResponse.refreshToken);
await expect(
authService.refresh({ refreshToken: loginResponse.refreshToken }),
).rejects.toThrow('Refresh token is invalid.');
});
it('rejects access tokens on the refresh endpoint', async () => {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
await authService.verifyEmail(token ?? undefined);
const loginResponse = await authService.login({
email: 'user@example.com',
password: 'password123',
});
await expect(
authService.refresh({ refreshToken: loginResponse.accessToken }),
).rejects.toThrow('Refresh token is invalid.');
});
it('validates access tokens', async () => {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
await authService.verifyEmail(token ?? undefined);
const loginResponse = await authService.login({
email: 'user@example.com',
password: 'password123',
});
const payload = await authService.verifyAccessToken(loginResponse.accessToken);
expect(payload.type).toBe('access');
expect(payload.email).toBe('user@example.com');
await expect(
authService.verifyAccessToken(loginResponse.refreshToken),
).rejects.toThrow('Access token is invalid.');
});
it('rejects duplicate registrations', async () => {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
await expect(
authService.register({
email: 'user@example.com',
password: 'password123',
}),
).rejects.toThrow('Email is already registered.');
});
});

View File

@@ -0,0 +1,333 @@
import {
BadRequestException,
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
import { Repository } from 'typeorm';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import {
AuthTokenResponse,
AuthTokens,
JwtTokenPayload,
PublicUser,
} from './auth.types';
import { AppEvents } from '../events/app-events';
import { RefreshTokenEntity } from './refresh-token.entity';
import { UserEntity } from './user.entity';
@Injectable()
export class AuthService {
private readonly accessTokenExpiresIn = '15m';
private readonly refreshTokenExpiresIn = '7d';
private readonly accessTokenSecret =
process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret';
private readonly refreshTokenSecret =
process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret';
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly jwtService: JwtService,
@InjectRepository(UserEntity)
private readonly usersRepository: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity)
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
) {}
async register(
registerDto: RegisterDto,
): Promise<{ message: string; user: PublicUser }> {
const email = this.normalizeEmail(registerDto.email);
const password = this.requirePassword(registerDto.password);
const name = this.normalizeName(registerDto.name);
const existingUser = await this.usersRepository.findOne({
where: { email },
});
if (existingUser) {
throw new ConflictException('Email is already registered.');
}
const verificationToken = this.createToken();
const user = this.usersRepository.create({
id: randomUUID(),
email,
name,
passwordHash: this.hashPassword(password),
verificationToken,
verified: false,
});
const savedUser = await this.usersRepository.save(user);
this.eventEmitter.emit(AppEvents.UserRegistered, {
email,
verificationUrl: this.createVerificationUrl(verificationToken),
});
return {
message: 'Registration successful. Please verify your email address.',
user: this.toPublicUser(savedUser),
};
}
async verifyEmail(
token?: string,
): Promise<{ message: string; user: PublicUser }> {
if (!token) {
throw new BadRequestException('Verification token is required.');
}
const user = await this.usersRepository.findOne({
where: { verificationToken: token },
});
if (!user) {
throw new BadRequestException('Verification token is invalid.');
}
user.verified = true;
user.verificationToken = null;
try {
const savedUser = await this.usersRepository.save(user);
return {
message: 'Email verified successfully.',
user: this.toPublicUser(savedUser),
};
} catch {
throw new BadRequestException('user not saved.')
}
}
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
const email = this.normalizeEmail(loginDto.email);
const password = this.requirePassword(loginDto.password);
const user = await this.usersRepository.findOne({ where: { email } });
if (!user || !this.passwordMatches(password, user.passwordHash)) {
throw new UnauthorizedException('Invalid email or password.');
}
if (!user.verified) {
throw new UnauthorizedException('Please verify your email before login.');
}
return {
...(await this.createAuthTokens(user)),
user: this.toPublicUser(user),
};
}
async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise<AuthTokenResponse> {
const refreshToken = this.requireRefreshToken(refreshTokenDto.refreshToken);
const payload = this.verifyRefreshToken(refreshToken);
const tokenRecord = await this.refreshTokensRepository.findOne({
where: { jti: payload.jti },
});
if (
!tokenRecord ||
tokenRecord.userId !== payload.sub ||
tokenRecord.expiresAt.getTime() <= Date.now() ||
!this.tokenMatches(refreshToken, tokenRecord.tokenHash)
) {
throw new UnauthorizedException('Refresh token is invalid.');
}
const user = await this.usersRepository.findOne({
where: { id: payload.sub },
});
if (!user || !user.verified) {
throw new UnauthorizedException('Refresh token is invalid.');
}
await this.refreshTokensRepository.delete({ jti: payload.jti });
return {
...(await this.createAuthTokens(user)),
user: this.toPublicUser(user),
};
}
async verifyAccessToken(accessToken: string): Promise<JwtTokenPayload> {
try {
const payload = this.jwtService.verify<JwtTokenPayload>(accessToken, {
secret: this.accessTokenSecret,
});
if (payload.type !== 'access') {
throw new UnauthorizedException('Access token is invalid.');
}
const user = await this.usersRepository.findOne({
where: { id: payload.sub },
});
if (!user || !user.verified) {
throw new UnauthorizedException('Access token is invalid.');
}
return payload;
} catch {
throw new UnauthorizedException('Access token is invalid.');
}
}
async getUserDisplayName(userId: string): Promise<string> {
const user = await this.usersRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new UnauthorizedException('Authenticated user is required.');
}
return user.name || user.email;
}
private normalizeEmail(email?: string): string {
const normalizedEmail = email?.trim().toLowerCase();
if (
!normalizedEmail ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)
) {
throw new BadRequestException('A valid email is required.');
}
return normalizedEmail;
}
private normalizeName(name?: string): string | undefined {
const normalizedName = name?.trim();
return normalizedName || undefined;
}
private requirePassword(password?: string): string {
if (!password || password.length < 8) {
throw new BadRequestException(
'Password must contain at least 8 characters.',
);
}
return password;
}
private hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 64).toString('hex');
return `${salt}:${hash}`;
}
private passwordMatches(password: string, passwordHash: string): boolean {
const [salt, storedHash] = passwordHash.split(':');
if (!salt || !storedHash) {
return false;
}
const attemptedHash = scryptSync(password, salt, 64);
const storedHashBuffer = Buffer.from(storedHash, 'hex');
return (
storedHashBuffer.length === attemptedHash.length &&
timingSafeEqual(storedHashBuffer, attemptedHash)
);
}
private async createAuthTokens(user: UserEntity): Promise<AuthTokens> {
const refreshTokenJti = randomUUID();
const accessTokenPayload: JwtTokenPayload = {
sub: user.id,
email: user.email,
type: 'access',
};
const refreshTokenPayload: JwtTokenPayload = {
sub: user.id,
email: user.email,
type: 'refresh',
jti: refreshTokenJti,
};
const accessToken = this.jwtService.sign(accessTokenPayload, {
expiresIn: this.accessTokenExpiresIn,
secret: this.accessTokenSecret,
});
const refreshToken = this.jwtService.sign(refreshTokenPayload, {
expiresIn: this.refreshTokenExpiresIn,
secret: this.refreshTokenSecret,
});
await this.refreshTokensRepository.save(
this.refreshTokensRepository.create({
jti: refreshTokenJti,
userId: user.id,
tokenHash: this.hashToken(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
}),
);
return { accessToken, refreshToken };
}
private verifyRefreshToken(refreshToken: string): JwtTokenPayload & {
jti: string;
} {
try {
const payload = this.jwtService.verify<JwtTokenPayload>(refreshToken, {
secret: this.refreshTokenSecret,
});
if (payload.type !== 'refresh' || !payload.jti) {
throw new UnauthorizedException('Refresh token is invalid.');
}
return { ...payload, jti: payload.jti };
} catch {
throw new UnauthorizedException('Refresh token is invalid.');
}
}
private requireRefreshToken(refreshToken?: string): string {
if (!refreshToken) {
throw new BadRequestException('Refresh token is required.');
}
return refreshToken;
}
private hashToken(token: string): string {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(token, salt, 64).toString('hex');
return `${salt}:${hash}`;
}
private tokenMatches(token: string, tokenHash: string): boolean {
return this.passwordMatches(token, tokenHash);
}
private createToken(): string {
return randomBytes(32).toString('hex');
}
private createVerificationUrl(token: string): string {
const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200';
return `${clientUrl}/verify-email?token=${token}`;
}
private toPublicUser(user: UserEntity): PublicUser {
return {
id: user.id,
email: user.email,
name: user.name ?? undefined,
verified: user.verified,
};
}
}

View File

@@ -0,0 +1,37 @@
import { Request } from 'express';
export interface AuthUser {
id: string;
email: string;
name?: string;
passwordHash: string;
verificationToken?: string;
verified: boolean;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface AuthTokenResponse extends AuthTokens {
user: PublicUser;
}
export interface JwtTokenPayload {
sub: string;
email: string;
type: 'access' | 'refresh';
jti?: string;
}
export interface AuthenticatedRequest extends Request {
user?: JwtTokenPayload;
}
export interface PublicUser {
id: string;
email: string;
name?: string;
verified: boolean;
}

View File

@@ -0,0 +1,4 @@
export class LoginDto {
email?: string;
password?: string;
}

View File

@@ -0,0 +1,3 @@
export class RefreshTokenDto {
refreshToken?: string;
}

View File

@@ -0,0 +1,5 @@
export class RegisterDto {
email?: string;
name?: string;
password?: string;
}

View File

@@ -0,0 +1,113 @@
import { ExecutionContext } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { JwtModule } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AuthService } from './auth.service';
import { AuthenticatedRequest } from './auth.types';
import { JwtAuthGuard } from './jwt-auth.guard';
import { MailModule } from '../mail/mail.module';
import { MailService } from '../mail/mail.service';
import { RefreshTokenEntity } from './refresh-token.entity';
import { UserEntity } from './user.entity';
import { InMemoryRepository } from '../testing/in-memory-repository';
describe('JwtAuthGuard', () => {
let module: TestingModule;
let authService: AuthService;
let guard: JwtAuthGuard;
let mailService: MailService;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule],
providers: [
AuthService,
JwtAuthGuard,
{
provide: getRepositoryToken(UserEntity),
useValue: new InMemoryRepository<UserEntity>(),
},
{
provide: getRepositoryToken(RefreshTokenEntity),
useValue: new InMemoryRepository<RefreshTokenEntity>(),
},
],
}).compile();
await module.init();
authService = module.get<AuthService>(AuthService);
guard = module.get<JwtAuthGuard>(JwtAuthGuard);
mailService = module.get<MailService>(MailService);
});
afterEach(async () => {
await module.close();
});
it('allows requests with a valid access token and attaches the payload', async () => {
const { accessToken } = await registerVerifiedUserAndLogin();
const request = createRequest(`Bearer ${accessToken}`);
const context = createExecutionContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(request.user?.type).toBe('access');
expect(request.user?.email).toBe('user@example.com');
});
it('rejects missing authorization headers', async () => {
const request = createRequest();
const context = createExecutionContext(request);
await expect(guard.canActivate(context)).rejects.toThrow(
'Authorization bearer token is required.',
);
});
it('rejects refresh tokens', async () => {
const { refreshToken } = await registerVerifiedUserAndLogin();
const request = createRequest(`Bearer ${refreshToken}`);
const context = createExecutionContext(request);
await expect(guard.canActivate(context)).rejects.toThrow(
'Access token is invalid.',
);
});
async function registerVerifiedUserAndLogin(): Promise<{
accessToken: string;
refreshToken: string;
}> {
await authService.register({
email: 'user@example.com',
password: 'password123',
});
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
await authService.verifyEmail(token ?? undefined);
return authService.login({
email: 'user@example.com',
password: 'password123',
});
}
function createRequest(
authorization?: string,
): Partial<AuthenticatedRequest> {
return {
headers: authorization ? { authorization } : {},
};
}
function createExecutionContext(
request: Partial<AuthenticatedRequest>,
): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => request,
}),
} as unknown as ExecutionContext;
}
});

View File

@@ -0,0 +1,42 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthenticatedRequest } from './auth.types';
import { AuthService } from './auth.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const accessToken = this.extractBearerToken(request);
request.user = await this.authService.verifyAccessToken(accessToken);
return true;
}
private extractBearerToken(request: AuthenticatedRequest): string {
const authorizationHeader = request.headers.authorization;
if (!authorizationHeader) {
throw new UnauthorizedException(
'Authorization bearer token is required.',
);
}
const [scheme, token] = authorizationHeader.split(' ');
if (scheme.toLowerCase() !== 'bearer' || !token) {
throw new UnauthorizedException(
'Authorization bearer token is required.',
);
}
return token;
}
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('refresh_tokens')
export class RefreshTokenEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
jti!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
userId!: string;
@Column({ type: 'varchar', length: 255 })
tokenHash!: string;
@Column({ type: 'datetime', precision: 3 })
expiresAt!: Date;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@ManyToOne(() => UserEntity, (user) => user.refreshTokens, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user?: UserEntity;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { UserListEntity } from '../lists/user-list.entity';
import { RefreshTokenEntity } from './refresh-token.entity';
@Entity('users')
export class UserEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index({ unique: true })
@Column({ type: 'varchar', length: 320 })
email!: string;
@Column({ type: 'varchar', length: 160, nullable: true })
name?: string | null;
@Column({ type: 'varchar', length: 255 })
passwordHash!: string;
@Index({ unique: true })
@Column({ type: 'varchar', length: 128, nullable: true })
verificationToken?: string | null;
@Column({ type: 'boolean', default: false })
verified!: boolean;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@OneToMany(() => RefreshTokenEntity, (refreshToken) => refreshToken.user)
refreshTokens?: RefreshTokenEntity[];
@OneToMany(() => ListTemplateEntity, (template) => template.owner)
templates?: ListTemplateEntity[];
@OneToMany(() => UserListEntity, (list) => list.owner)
lists?: UserListEntity[];
}

View File

@@ -0,0 +1,31 @@
import 'dotenv/config';
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { UserEntity } from '../auth/user.entity';
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
import { TemplateSeedEntity } from '../list-templates/template-seed.entity';
import { UserListEntity } from '../lists/user-list.entity';
import { UserListItemEntity } from '../lists/user-list-item.entity';
export default new DataSource({
type: 'mysql',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? '3306'),
username: process.env.DB_USERNAME ?? 'root',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_DATABASE ?? 'listify',
synchronize: false,
logging: process.env.DB_LOGGING === 'true',
entities: [
UserEntity,
RefreshTokenEntity,
ListTemplateEntity,
ListTemplateItemEntity,
TemplateSeedEntity,
UserListEntity,
UserListItemEntity,
],
migrations: ['src/database/migrations/*.ts'],
});

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class GeneratedMigration1780932637916 implements MigrationInterface {
name = 'GeneratedMigration1780932637916'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_list_template_items_template_id\``);
await queryRunner.query(`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_list_templates_owner_id\``);
await queryRunner.query(`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_user_list_items_list_id\``);
await queryRunner.query(`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_user_lists_owner_id\``);
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_refresh_tokens_user_id\``);
await queryRunner.query(`ALTER TABLE \`list_template_seeds\` DROP FOREIGN KEY \`FK_list_template_seeds_owner_id\``);
await queryRunner.query(`DROP INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\``);
await queryRunner.query(`DROP INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\``);
await queryRunner.query(`DROP INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\``);
await queryRunner.query(`DROP INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\``);
await queryRunner.query(`DROP INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\``);
await queryRunner.query(`DROP INDEX \`IDX_users_email\` ON \`users\``);
await queryRunner.query(`DROP INDEX \`IDX_users_verification_token\` ON \`users\``);
await queryRunner.query(`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_97672ac88f789774dd47f7c8be\` (\`email\`)`);
await queryRunner.query(`ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_945333aaddfc5b9021b2ee94d5\` (\`verificationToken\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\` (\`templateId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\` (\`ownerId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\` (\`listId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\` (\`ownerId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\` (\`userId\`)`);
await queryRunner.query(`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_82feb6202f10c7f7283d3980144\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_dca36cb201077233743d7355d21\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_7dc61846f78234b1701413206df\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_20f32f84af2f8a3aa60d7023260\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_610102b60fea1455310ccd299de\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` DROP FOREIGN KEY \`FK_610102b60fea1455310ccd299de\``);
await queryRunner.query(`ALTER TABLE \`user_lists\` DROP FOREIGN KEY \`FK_20f32f84af2f8a3aa60d7023260\``);
await queryRunner.query(`ALTER TABLE \`user_list_items\` DROP FOREIGN KEY \`FK_7dc61846f78234b1701413206df\``);
await queryRunner.query(`ALTER TABLE \`list_templates\` DROP FOREIGN KEY \`FK_dca36cb201077233743d7355d21\``);
await queryRunner.query(`ALTER TABLE \`list_template_items\` DROP FOREIGN KEY \`FK_82feb6202f10c7f7283d3980144\``);
await queryRunner.query(`DROP INDEX \`IDX_610102b60fea1455310ccd299d\` ON \`refresh_tokens\``);
await queryRunner.query(`DROP INDEX \`IDX_20f32f84af2f8a3aa60d702326\` ON \`user_lists\``);
await queryRunner.query(`DROP INDEX \`IDX_7dc61846f78234b1701413206d\` ON \`user_list_items\``);
await queryRunner.query(`DROP INDEX \`IDX_dca36cb201077233743d7355d2\` ON \`list_templates\``);
await queryRunner.query(`DROP INDEX \`IDX_82feb6202f10c7f7283d398014\` ON \`list_template_items\``);
await queryRunner.query(`ALTER TABLE \`users\` DROP INDEX \`IDX_945333aaddfc5b9021b2ee94d5\``);
await queryRunner.query(`ALTER TABLE \`users\` DROP INDEX \`IDX_97672ac88f789774dd47f7c8be\``);
await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_users_verification_token\` ON \`users\` (\`verificationToken\`)`);
await queryRunner.query(`CREATE UNIQUE INDEX \`IDX_users_email\` ON \`users\` (\`email\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_refresh_tokens_user_id\` ON \`refresh_tokens\` (\`userId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_user_lists_owner_id\` ON \`user_lists\` (\`ownerId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_user_list_items_list_id\` ON \`user_list_items\` (\`listId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_list_templates_owner_id\` ON \`list_templates\` (\`ownerId\`)`);
await queryRunner.query(`CREATE INDEX \`IDX_list_template_items_template_id\` ON \`list_template_items\` (\`templateId\`)`);
await queryRunner.query(`ALTER TABLE \`list_template_seeds\` ADD CONSTRAINT \`FK_list_template_seeds_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`refresh_tokens\` ADD CONSTRAINT \`FK_refresh_tokens_user_id\` FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`user_lists\` ADD CONSTRAINT \`FK_user_lists_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`user_list_items\` ADD CONSTRAINT \`FK_user_list_items_list_id\` FOREIGN KEY (\`listId\`) REFERENCES \`user_lists\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`list_templates\` ADD CONSTRAINT \`FK_list_templates_owner_id\` FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE \`list_template_items\` ADD CONSTRAINT \`FK_list_template_items_template_id\` FOREIGN KEY (\`templateId\`) REFERENCES \`list_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View File

@@ -0,0 +1,8 @@
export const AppEvents = {
UserRegistered: 'user.registered',
} as const;
export interface UserRegisteredEvent {
email: string;
verificationUrl: string;
}

View File

@@ -0,0 +1,4 @@
export class CreateListFromTemplateDto {
name?: string;
description?: string;
}

View File

@@ -0,0 +1,15 @@
import { ListTemplateKind } from '../list-template.types';
export class CreateListTemplateItemDto {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export class CreateListTemplateDto {
name?: string;
description?: string;
kind?: ListTemplateKind;
items?: CreateListTemplateItemDto[];
}

View File

@@ -0,0 +1,17 @@
export class AddListTemplateItemDto {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export class UpdateListTemplateItemDto {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export class ReorderListTemplateItemsDto {
itemIds?: string[];
}

View File

@@ -0,0 +1,9 @@
import { ListTemplateKind } from '../list-template.types';
import { CreateListTemplateItemDto } from './create-list-template.dto';
export class UpdateListTemplateDto {
name?: string;
description?: string;
kind?: ListTemplateKind;
items?: CreateListTemplateItemDto[];
}

View File

@@ -0,0 +1,57 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ListTemplateEntity } from './list-template.entity';
@Entity('list_template_items')
export class ListTemplateItemEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
templateId!: string;
@Column({ type: 'varchar', length: 220 })
title!: string;
@Column({ type: 'text', nullable: true })
notes?: string | null;
@Column({ type: 'float', nullable: true })
quantity?: number | null;
@Column({ type: 'boolean', default: true })
required!: boolean;
@Column({ type: 'int' })
position!: number;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@ManyToOne(() => ListTemplateEntity, (template) => template.items, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'templateId' })
template?: ListTemplateEntity;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserEntity } from '../auth/user.entity';
import { ListTemplateItemEntity } from './list-template-item.entity';
import type { ListTemplateKind } from './list-template.types';
@Entity('list_templates')
export class ListTemplateEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
ownerId!: string;
@Column({ type: 'varchar', length: 160 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string | null;
@Column({ type: 'varchar', length: 32, default: 'custom' })
kind!: ListTemplateKind;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@ManyToOne(() => UserEntity, (user) => user.templates, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'ownerId' })
owner?: UserEntity;
@OneToMany(() => ListTemplateItemEntity, (item) => item.template, {
cascade: ['insert', 'update'],
})
items!: ListTemplateItemEntity[];
}

View File

@@ -0,0 +1,51 @@
export type ListTemplateKind = 'packing' | 'shopping' | 'todo' | 'custom';
export interface ListTemplateItem {
id: string;
title: string;
notes?: string;
quantity?: number;
required: boolean;
position: number;
createdAt: string;
updatedAt: string;
}
export interface ListTemplate {
id: string;
ownerId: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: ListTemplateItem[];
createdAt: string;
updatedAt: string;
}
export interface UserListItem {
id: string;
sourceTemplateItemId?: string;
title: string;
notes?: string;
quantity?: number;
required: boolean;
checked: boolean;
checkedAt?: string;
checkedByUserId?: string;
checkedByName?: string;
position: number;
createdAt: string;
updatedAt: string;
}
export interface UserList {
id: string;
ownerId: string;
sourceTemplateId?: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: UserListItem[];
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,170 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ListsService } from '../lists/lists.service';
import { CreateListFromTemplateDto } from './dto/create-list-from-template.dto';
import { CreateListTemplateDto } from './dto/create-list-template.dto';
import {
AddListTemplateItemDto,
ReorderListTemplateItemsDto,
UpdateListTemplateItemDto,
} from './dto/list-template-item.dto';
import { UpdateListTemplateDto } from './dto/update-list-template.dto';
import { ListTemplatesService } from './list-templates.service';
import type { AuthenticatedRequest } from '../auth/auth.types';
@Controller('list-templates')
@UseGuards(JwtAuthGuard)
export class ListTemplatesController {
constructor(
private readonly listTemplatesService: ListTemplatesService,
private readonly listsService: ListsService,
) {}
@Post()
createTemplate(
@Req() request: AuthenticatedRequest,
@Body() createDto: CreateListTemplateDto,
) {
return this.listTemplatesService.createTemplate(
this.requireUserId(request),
createDto,
);
}
@Get()
listTemplates(@Req() request: AuthenticatedRequest) {
return this.listTemplatesService.listTemplates(this.requireUserId(request));
}
@Get('lists')
listCreatedLists(@Req() request: AuthenticatedRequest) {
return this.listsService.listLists(this.requireUserId(request));
}
@Get(':templateId')
getTemplate(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
) {
return this.listTemplatesService.getTemplate(
this.requireUserId(request),
templateId,
);
}
@Patch(':templateId')
updateTemplate(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
@Body() updateDto: UpdateListTemplateDto,
) {
return this.listTemplatesService.updateTemplate(
this.requireUserId(request),
templateId,
updateDto,
);
}
@Delete(':templateId')
deleteTemplate(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
) {
return this.listTemplatesService.deleteTemplate(
this.requireUserId(request),
templateId,
);
}
@Post(':templateId/items')
addItem(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
@Body() addDto: AddListTemplateItemDto,
) {
return this.listTemplatesService.addItem(
this.requireUserId(request),
templateId,
addDto,
);
}
@Patch(':templateId/items/order')
reorderItems(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
@Body() reorderDto: ReorderListTemplateItemsDto,
) {
return this.listTemplatesService.reorderItems(
this.requireUserId(request),
templateId,
reorderDto,
);
}
@Patch(':templateId/items/:itemId')
updateItem(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
@Param('itemId') itemId: string,
@Body() updateDto: UpdateListTemplateItemDto,
) {
return this.listTemplatesService.updateItem(
this.requireUserId(request),
templateId,
itemId,
updateDto,
);
}
@Delete(':templateId/items/:itemId')
deleteItem(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
@Param('itemId') itemId: string,
) {
return this.listTemplatesService.deleteItem(
this.requireUserId(request),
templateId,
itemId,
);
}
@Post(':templateId/lists')
async createListFromTemplate(
@Req() request: AuthenticatedRequest,
@Param('templateId') templateId: string,
@Body() createDto: CreateListFromTemplateDto,
) {
const ownerId = this.requireUserId(request);
const template = await this.listTemplatesService.getTemplate(
ownerId,
templateId,
);
return this.listsService.createListFromTemplate(
ownerId,
template,
createDto,
);
}
private requireUserId(request: AuthenticatedRequest): string {
if (!request.user?.sub) {
throw new UnauthorizedException('Authenticated user is required.');
}
return request.user.sub;
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { ListsModule } from '../lists/lists.module';
import { ListTemplatesController } from './list-templates.controller';
import { ListTemplateEntity } from './list-template.entity';
import { ListTemplateItemEntity } from './list-template-item.entity';
import { ListTemplatesService } from './list-templates.service';
import { TemplateSeedEntity } from './template-seed.entity';
@Module({
imports: [
AuthModule,
ListsModule,
TypeOrmModule.forFeature([
ListTemplateEntity,
ListTemplateItemEntity,
TemplateSeedEntity,
]),
],
controllers: [ListTemplatesController],
providers: [ListTemplatesService],
exports: [ListTemplatesService],
})
export class ListTemplatesModule {}

View File

@@ -0,0 +1,183 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { InMemoryRepository } from '../testing/in-memory-repository';
import { ListTemplateEntity } from './list-template.entity';
import { ListTemplateItemEntity } from './list-template-item.entity';
import { ListTemplatesService } from './list-templates.service';
import { TemplateSeedEntity } from './template-seed.entity';
describe('ListTemplatesService', () => {
let service: ListTemplatesService;
beforeEach(() => {
service = new ListTemplatesService(
new InMemoryRepository<ListTemplateEntity>() as never,
new InMemoryRepository<ListTemplateItemEntity>() as never,
new InMemoryRepository<TemplateSeedEntity>() as never,
);
});
it('provides three example templates for each user on first list access', async () => {
const templates = await service.listTemplates('user-1');
const templateNames = templates.map((template) => template.name);
expect(templates).toHaveLength(3);
expect(templateNames).toContain('Urlaub');
expect(templateNames).toContain('Wocheneinkauf');
expect(templateNames).toContain('Wochenplanung');
expect(templates.every((template) => template.items.length > 0)).toBe(true);
});
it('keeps seeded templates editable and deleted templates deleted', async () => {
const template = (await service.listTemplates('user-1'))[0];
const updatedTemplate = await service.updateTemplate('user-1', template.id, {
name: 'Meine Vorlage',
});
await service.deleteTemplate('user-1', template.id);
expect(updatedTemplate.name).toBe('Meine Vorlage');
await expect(service.listTemplates('user-1')).resolves.toHaveLength(2);
expect(
(await service.listTemplates('user-1'))
.some((existingTemplate) => existingTemplate.id === template.id),
).toBe(false);
});
it('creates and lists templates for the owning user', async () => {
const template = await service.createTemplate('user-1', {
name: 'Urlaub',
description: 'Standard-Packliste',
kind: 'packing',
items: [
{ title: 'Pass', required: true },
{ title: 'Sonnencreme', quantity: 1 },
],
});
expect(template.name).toBe('Urlaub');
expect(template.kind).toBe('packing');
expect(template.items).toHaveLength(2);
expect(template.items[0].title).toBe('Pass');
expect(
(await service.listTemplates('user-1'))
.some((existingTemplate) => existingTemplate.id === template.id),
).toBe(true);
expect(
(await service.listTemplates('user-2'))
.some((existingTemplate) => existingTemplate.id === template.id),
).toBe(false);
});
it('updates template metadata and item content', async () => {
const template = await service.createTemplate('user-1', {
name: 'Einkauf',
kind: 'shopping',
items: [{ title: 'Milch' }],
});
const itemId = template.items[0].id;
const updatedTemplate = await service.updateTemplate('user-1', template.id, {
name: 'Wocheneinkauf',
});
const updatedItemTemplate = await service.updateItem(
'user-1',
template.id,
itemId,
{
title: 'Hafermilch',
quantity: 2,
required: false,
},
);
expect(updatedTemplate.name).toBe('Wocheneinkauf');
expect(updatedItemTemplate.items[0].title).toBe('Hafermilch');
expect(updatedItemTemplate.items[0].quantity).toBe(2);
expect(updatedItemTemplate.items[0].required).toBe(false);
});
it('adds and deletes template items while keeping positions stable', async () => {
const template = await service.createTemplate('user-1', {
name: 'Todo',
kind: 'todo',
items: [{ title: 'Erster Schritt' }],
});
const withAddedItem = await service.addItem('user-1', template.id, {
title: 'Zweiter Schritt',
});
const remainingTemplate = await service.deleteItem(
'user-1',
template.id,
withAddedItem.items[0].id,
);
expect(withAddedItem.items).toHaveLength(2);
expect(withAddedItem.items[1].position).toBe(1);
expect(remainingTemplate.items).toHaveLength(1);
expect(remainingTemplate.items[0].title).toBe('Zweiter Schritt');
expect(remainingTemplate.items[0].position).toBe(0);
});
it('reorders template items and persists positions', async () => {
const template = await service.createTemplate('user-1', {
name: 'Todo',
kind: 'todo',
items: [
{ title: 'Erster Schritt' },
{ title: 'Zweiter Schritt' },
{ title: 'Dritter Schritt' },
],
});
const reorderedTemplate = await service.reorderItems('user-1', template.id, {
itemIds: [
template.items[2].id,
template.items[0].id,
template.items[1].id,
],
});
const reloadedTemplate = await service.getTemplate('user-1', template.id);
expect(reorderedTemplate.items.map((item) => item.title)).toEqual([
'Dritter Schritt',
'Erster Schritt',
'Zweiter Schritt',
]);
expect(reloadedTemplate.items.map((item) => item.position)).toEqual([
0,
1,
2,
]);
});
it('does not allow users to access templates owned by other users', async () => {
const template = await service.createTemplate('user-1', {
name: 'Private Vorlage',
});
await expect(service.getTemplate('user-2', template.id)).rejects.toThrow(
ForbiddenException,
);
await expect(
service.updateTemplate('user-2', template.id, { name: 'Fremd' }),
).rejects.toThrow(ForbiddenException);
});
it('rejects invalid input and missing resources', async () => {
await expect(
service.createTemplate('user-1', { name: ' ' }),
).rejects.toThrow(
'List template name is required.',
);
await expect(
service.createTemplate('user-1', {
name: 'Ungueltig',
items: [{ title: '' }],
}),
).rejects.toThrow('List template item title is required.');
await expect(service.getTemplate('user-1', 'missing')).rejects.toThrow(
NotFoundException,
);
});
});

View File

@@ -0,0 +1,450 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import {
AddListTemplateItemDto,
ReorderListTemplateItemsDto,
UpdateListTemplateItemDto,
} from './dto/list-template-item.dto';
import {
CreateListTemplateDto,
CreateListTemplateItemDto,
} from './dto/create-list-template.dto';
import { UpdateListTemplateDto } from './dto/update-list-template.dto';
import {
ListTemplate,
ListTemplateItem,
ListTemplateKind,
} from './list-template.types';
import { ListTemplateEntity } from './list-template.entity';
import { ListTemplateItemEntity } from './list-template-item.entity';
import { TemplateSeedEntity } from './template-seed.entity';
@Injectable()
export class ListTemplatesService {
constructor(
@InjectRepository(ListTemplateEntity)
private readonly templatesRepository: Repository<ListTemplateEntity>,
@InjectRepository(ListTemplateItemEntity)
private readonly templateItemsRepository: Repository<ListTemplateItemEntity>,
@InjectRepository(TemplateSeedEntity)
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
) {}
async createTemplate(
ownerId: string,
createDto: CreateListTemplateDto,
): Promise<ListTemplate> {
const template = this.templatesRepository.create({
id: randomUUID(),
ownerId,
name: this.requireName(createDto.name),
description: this.normalizeOptionalText(createDto.description),
kind: this.normalizeKind(createDto.kind),
items: this.createTemplateItems(createDto.items ?? []),
});
return this.toListTemplate(await this.templatesRepository.save(template));
}
async listTemplates(ownerId: string): Promise<ListTemplate[]> {
await this.ensureExampleTemplates(ownerId);
const templates = await this.templatesRepository.find({
where: { ownerId },
relations: { items: true },
order: { name: 'ASC', items: { position: 'ASC' } },
});
return templates.map((template) => this.toListTemplate(template));
}
async getTemplate(
ownerId: string,
templateId: string,
): Promise<ListTemplate> {
return this.toListTemplate(await this.findOwnedTemplate(ownerId, templateId));
}
async updateTemplate(
ownerId: string,
templateId: string,
updateDto: UpdateListTemplateDto,
): Promise<ListTemplate> {
const template = await this.findOwnedTemplate(ownerId, templateId);
if (updateDto.name !== undefined) {
template.name = this.requireName(updateDto.name);
}
if (updateDto.description !== undefined) {
template.description = this.normalizeOptionalText(updateDto.description);
}
if (updateDto.kind !== undefined) {
template.kind = this.normalizeKind(updateDto.kind);
}
if (updateDto.items !== undefined) {
await this.templateItemsRepository.remove(template.items);
template.items = this.createTemplateItems(updateDto.items);
template.items.forEach((item) => {
item.templateId = template.id;
});
}
return this.toListTemplate(await this.templatesRepository.save(template));
}
async deleteTemplate(
ownerId: string,
templateId: string,
): Promise<{ message: string }> {
const template = await this.findOwnedTemplate(ownerId, templateId);
await this.templatesRepository.remove(template);
return { message: 'List template deleted.' };
}
async addItem(
ownerId: string,
templateId: string,
addDto: AddListTemplateItemDto,
): Promise<ListTemplate> {
const template = await this.findOwnedTemplate(ownerId, templateId);
const item = this.createTemplateItem(addDto, template.items.length);
item.templateId = template.id;
const savedItem = await this.templateItemsRepository.save(item);
template.items.push(savedItem);
await this.templatesRepository.save(template);
return this.getTemplate(ownerId, templateId);
}
async updateItem(
ownerId: string,
templateId: string,
itemId: string,
updateDto: UpdateListTemplateItemDto,
): Promise<ListTemplate> {
const template = await this.findOwnedTemplate(ownerId, templateId);
const item = this.findTemplateItem(template, itemId);
if (updateDto.title !== undefined) {
item.title = this.requireItemTitle(updateDto.title);
}
if (updateDto.notes !== undefined) {
item.notes = this.normalizeOptionalText(updateDto.notes);
}
if (updateDto.quantity !== undefined) {
item.quantity = this.normalizeQuantity(updateDto.quantity);
}
if (updateDto.required !== undefined) {
item.required = this.normalizeBoolean(updateDto.required, 'required');
}
await this.templateItemsRepository.save(item);
await this.templatesRepository.save(template);
return this.getTemplate(ownerId, templateId);
}
async reorderItems(
ownerId: string,
templateId: string,
reorderDto: ReorderListTemplateItemsDto,
): Promise<ListTemplate> {
const template = await this.findOwnedTemplate(ownerId, templateId);
const itemIds = reorderDto.itemIds;
if (!Array.isArray(itemIds)) {
throw new BadRequestException('Item ids must be an array.');
}
if (itemIds.length !== template.items.length) {
throw new BadRequestException('Item ids must include every template item.');
}
const uniqueItemIds = new Set(itemIds);
if (uniqueItemIds.size !== itemIds.length) {
throw new BadRequestException('Item ids must be unique.');
}
const itemsById = new Map(template.items.map((item) => [item.id, item]));
const reorderedItems = itemIds.map((itemId, index) => {
const item = itemsById.get(itemId);
if (!item) {
throw new BadRequestException('Item ids must include every template item.');
}
item.position = index;
return item;
});
await this.templateItemsRepository.save(reorderedItems);
await this.templatesRepository.save(template);
return this.getTemplate(ownerId, templateId);
}
async deleteItem(
ownerId: string,
templateId: string,
itemId: string,
): Promise<ListTemplate> {
const template = await this.findOwnedTemplate(ownerId, templateId);
const itemIndex = template.items.findIndex((item) => item.id === itemId);
if (itemIndex === -1) {
throw new NotFoundException('List template item was not found.');
}
const [itemToDelete] = template.items.splice(itemIndex, 1);
await this.templateItemsRepository.remove(itemToDelete);
template.items.forEach((item, index) => {
item.position = index;
});
await this.templateItemsRepository.save(template.items);
await this.templatesRepository.save(template);
return this.getTemplate(ownerId, templateId);
}
private async findOwnedTemplate(
ownerId: string,
templateId: string,
): Promise<ListTemplateEntity> {
const template = await this.templatesRepository.findOne({
where: { id: templateId },
relations: { items: true },
order: { items: { position: 'ASC' } },
});
if (!template) {
throw new NotFoundException('List template was not found.');
}
if (template.ownerId !== ownerId) {
throw new ForbiddenException('List template belongs to another user.');
}
template.items = template.items ?? [];
return template;
}
private async ensureExampleTemplates(ownerId: string): Promise<void> {
const seed = await this.templateSeedsRepository.findOne({
where: { ownerId },
});
if (seed) {
return;
}
await this.templatesRepository.save(
[
{
name: 'Urlaub',
description:
'Grundlage fuer Packlisten wie Sommerurlaub oder Staedtetrip.',
kind: 'packing' as const,
items: [
{ title: 'Pass oder Ausweis' },
{ title: 'Tickets und Buchungsbestaetigungen' },
{ title: 'Ladegeraete' },
{ title: 'Reiseapotheke' },
{ title: 'Sonnencreme', required: false },
],
},
{
name: 'Wocheneinkauf',
description: 'Basis fuer wiederkehrende Einkaufslisten.',
kind: 'shopping' as const,
items: [
{ title: 'Milch' },
{ title: 'Brot' },
{ title: 'Obst' },
{ title: 'Gemuese' },
{ title: 'Kaffee', required: false },
],
},
{
name: 'Wochenplanung',
description: 'Einfache Todo-Vorlage fuer die persoenliche Planung.',
kind: 'todo' as const,
items: [
{ title: 'Prioritaeten festlegen' },
{ title: 'Termine pruefen' },
{ title: 'Aufgaben planen' },
{ title: 'Offene Punkte nachfassen', required: false },
],
},
].map((template) =>
this.templatesRepository.create({
id: randomUUID(),
ownerId,
name: template.name,
description: template.description,
kind: template.kind,
items: this.createTemplateItems(template.items),
}),
),
);
await this.templateSeedsRepository.save(
this.templateSeedsRepository.create({ ownerId, seeded: true }),
);
}
private findTemplateItem(
template: ListTemplateEntity,
itemId: string,
): ListTemplateItemEntity {
const item = template.items.find(
(templateItem) => templateItem.id === itemId,
);
if (!item) {
throw new NotFoundException('List template item was not found.');
}
return item;
}
private createTemplateItems(
items: CreateListTemplateItemDto[],
): ListTemplateItemEntity[] {
if (!Array.isArray(items)) {
throw new BadRequestException('Items must be an array.');
}
return items.map((item, index) => this.createTemplateItem(item, index));
}
private createTemplateItem(
itemDto: CreateListTemplateItemDto,
position: number,
): ListTemplateItemEntity {
return this.templateItemsRepository.create({
id: randomUUID(),
title: this.requireItemTitle(itemDto.title),
notes: this.normalizeOptionalText(itemDto.notes),
quantity: this.normalizeQuantity(itemDto.quantity),
required:
itemDto.required === undefined
? true
: this.normalizeBoolean(itemDto.required, 'required'),
position,
});
}
private normalizeKind(kind?: ListTemplateKind): ListTemplateKind {
if (kind === undefined) {
return 'custom';
}
if (
kind !== 'packing' &&
kind !== 'shopping' &&
kind !== 'todo' &&
kind !== 'custom'
) {
throw new BadRequestException('List template kind is invalid.');
}
return kind;
}
private requireName(name?: string): string {
const normalizedName = name?.trim();
if (!normalizedName) {
throw new BadRequestException('List template name is required.');
}
return normalizedName;
}
private requireItemTitle(title?: string): string {
const normalizedTitle = title?.trim();
if (!normalizedTitle) {
throw new BadRequestException('List template item title is required.');
}
return normalizedTitle;
}
private normalizeOptionalText(value?: string): string | undefined {
const normalizedValue = value?.trim();
return normalizedValue || undefined;
}
private normalizeQuantity(quantity?: number): number | undefined {
if (quantity === undefined) {
return undefined;
}
if (
typeof quantity !== 'number' ||
!Number.isFinite(quantity) ||
quantity <= 0
) {
throw new BadRequestException('Quantity must be greater than zero.');
}
return quantity;
}
private normalizeBoolean(value: boolean, fieldName: string): boolean {
if (typeof value !== 'boolean') {
throw new BadRequestException(`${fieldName} must be a boolean.`);
}
return value;
}
private toListTemplate(template: ListTemplateEntity): ListTemplate {
return {
id: template.id,
ownerId: template.ownerId,
name: template.name,
description: template.description ?? undefined,
kind: template.kind,
items: (template.items ?? [])
.sort((left, right) => left.position - right.position)
.map((item) => this.toListTemplateItem(item)),
createdAt: this.toIsoString(template.createdAt),
updatedAt: this.toIsoString(template.updatedAt),
};
}
private toListTemplateItem(item: ListTemplateItemEntity): ListTemplateItem {
return {
id: item.id,
title: item.title,
notes: item.notes ?? undefined,
quantity: item.quantity ?? undefined,
required: item.required,
position: item.position,
createdAt: this.toIsoString(item.createdAt),
updatedAt: this.toIsoString(item.updatedAt),
};
}
private toIsoString(value?: Date): string {
return (value ?? new Date()).toISOString();
}
}

View File

@@ -0,0 +1,17 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
@Entity('list_template_seeds')
export class TemplateSeedEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
ownerId!: string;
@Column({ type: 'boolean', default: true })
seeded!: boolean;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
}

View File

@@ -0,0 +1,7 @@
import { ListTemplateKind } from '../../list-templates/list-template.types';
export class CreateListDto {
name?: string;
description?: string;
kind?: ListTemplateKind;
}

View File

@@ -0,0 +1,14 @@
export class AddListItemDto {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export class UpdateListItemDto {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
checked?: boolean;
}

View File

@@ -0,0 +1,7 @@
import { ListTemplateKind } from '../../list-templates/list-template.types';
export class UpdateListDto {
name?: string;
description?: string;
kind?: ListTemplateKind;
}

View File

@@ -0,0 +1,122 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AuthService } from '../auth/auth.service';
import { CreateListDto } from './dto/create-list.dto';
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
import { UpdateListDto } from './dto/update-list.dto';
import { ListsService } from './lists.service';
import type { AuthenticatedRequest } from '../auth/auth.types';
@Controller('lists')
@UseGuards(JwtAuthGuard)
export class ListsController {
constructor(
private readonly authService: AuthService,
private readonly listsService: ListsService,
) {}
@Post()
createList(
@Req() request: AuthenticatedRequest,
@Body() createDto: CreateListDto,
) {
return this.listsService.createList(this.requireUserId(request), createDto);
}
@Get()
listLists(@Req() request: AuthenticatedRequest) {
return this.listsService.listLists(this.requireUserId(request));
}
@Get(':listId')
getList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
) {
return this.listsService.getList(this.requireUserId(request), listId);
}
@Patch(':listId')
updateList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Body() updateDto: UpdateListDto,
) {
return this.listsService.updateList(
this.requireUserId(request),
listId,
updateDto,
);
}
@Delete(':listId')
deleteList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
) {
return this.listsService.deleteList(this.requireUserId(request), listId);
}
@Post(':listId/items')
addItem(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Body() addDto: AddListItemDto,
) {
return this.listsService.addItem(
this.requireUserId(request),
listId,
addDto,
);
}
@Patch(':listId/items/:itemId')
async updateItem(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Param('itemId') itemId: string,
@Body() updateDto: UpdateListItemDto,
) {
const userId = this.requireUserId(request);
return this.listsService.updateItem(
userId,
listId,
itemId,
updateDto,
await this.authService.getUserDisplayName(userId),
);
}
@Delete(':listId/items/:itemId')
deleteItem(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Param('itemId') itemId: string,
) {
return this.listsService.deleteItem(
this.requireUserId(request),
listId,
itemId,
);
}
private requireUserId(request: AuthenticatedRequest): string {
if (!request.user?.sub) {
throw new UnauthorizedException('Authenticated user is required.');
}
return request.user.sub;
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { ListsController } from './lists.controller';
import { ListsService } from './lists.service';
import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity';
@Module({
imports: [AuthModule, TypeOrmModule.forFeature([UserListEntity, UserListItemEntity])],
controllers: [ListsController],
providers: [ListsService],
exports: [ListsService],
})
export class ListsModule {}

View File

@@ -0,0 +1,160 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ListTemplate } from '../list-templates/list-template.types';
import { InMemoryRepository } from '../testing/in-memory-repository';
import { ListsService } from './lists.service';
import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity';
describe('ListsService', () => {
let service: ListsService;
beforeEach(() => {
service = new ListsService(
new InMemoryRepository<UserListEntity>() as never,
new InMemoryRepository<UserListItemEntity>() as never,
);
});
it('creates and lists concrete lists for the owning user', async () => {
const list = await service.createList('user-1', {
name: 'Sommerurlaub 2026',
description: 'Konkrete Packliste',
kind: 'packing',
});
expect(list.name).toBe('Sommerurlaub 2026');
expect(list.kind).toBe('packing');
expect(list.items).toHaveLength(0);
expect(list.sourceTemplateId).toBeUndefined();
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
await expect(service.listLists('user-2')).resolves.toHaveLength(0);
});
it('updates and deletes concrete lists', async () => {
const list = await service.createList('user-1', {
name: 'Todo',
kind: 'todo',
});
const updatedList = await service.updateList('user-1', list.id, {
name: 'Wochenaufgaben',
description: 'Fokusliste',
});
const deleteResponse = await service.deleteList('user-1', list.id);
expect(updatedList.name).toBe('Wochenaufgaben');
expect(updatedList.description).toBe('Fokusliste');
expect(deleteResponse.message).toBe('List deleted.');
await expect(service.listLists('user-1')).resolves.toHaveLength(0);
});
it('adds, updates, checks and deletes list items', async () => {
const list = await service.createList('user-1', {
name: 'Einkauf',
kind: 'shopping',
});
const withFirstItem = await service.addItem('user-1', list.id, {
title: 'Milch',
quantity: 1,
});
const withSecondItem = await service.addItem('user-1', list.id, {
title: 'Brot',
});
const updatedList = await service.updateItem(
'user-1',
list.id,
withFirstItem.items[0].id,
{
title: 'Hafermilch',
quantity: 2,
checked: true,
required: false,
},
);
const remainingList = await service.deleteItem(
'user-1',
list.id,
withSecondItem.items[0].id,
);
expect(withSecondItem.items).toHaveLength(2);
expect(updatedList.items[0].title).toBe('Hafermilch');
expect(updatedList.items[0].quantity).toBe(2);
expect(updatedList.items[0].checked).toBe(true);
expect(updatedList.items[0].required).toBe(false);
expect(remainingList.items).toHaveLength(1);
expect(remainingList.items[0].title).toBe('Brot');
expect(remainingList.items[0].position).toBe(0);
});
it('creates a concrete list from a template', async () => {
const template: ListTemplate = {
id: 'template-1',
ownerId: 'user-1',
name: 'Urlaub',
kind: 'packing',
items: [
{
id: 'template-item-1',
title: 'Pass',
required: true,
position: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'template-item-2',
title: 'Tickets',
required: true,
position: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const list = await service.createListFromTemplate('user-1', template, {
name: 'Sommerurlaub 2026',
});
expect(list.name).toBe('Sommerurlaub 2026');
expect(list.sourceTemplateId).toBe(template.id);
expect(list.items).toHaveLength(2);
expect(list.items[0].checked).toBe(false);
expect(list.items[0].sourceTemplateItemId).toBe(template.items[0].id);
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
});
it('does not allow users to access lists owned by other users', async () => {
const list = await service.createList('user-1', {
name: 'Private Liste',
});
await expect(service.getList('user-2', list.id)).rejects.toThrow(
ForbiddenException,
);
await expect(
service.updateList('user-2', list.id, { name: 'Fremd' }),
).rejects.toThrow(ForbiddenException);
});
it('rejects invalid input and missing resources', async () => {
const list = await service.createList('user-1', { name: 'Liste' });
await expect(service.createList('user-1', { name: ' ' })).rejects.toThrow(
'List name is required.',
);
await expect(
service.addItem('user-1', list.id, { title: '', quantity: 1 }),
).rejects.toThrow('List item title is required.');
await expect(
service.addItem('user-1', list.id, { title: 'Milch', quantity: 0 }),
).rejects.toThrow('Quantity must be greater than zero.');
await expect(service.getList('user-1', 'missing')).rejects.toThrow(
NotFoundException,
);
});
});

View File

@@ -0,0 +1,371 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import {
ListTemplate,
ListTemplateKind,
UserList,
UserListItem,
} from '../list-templates/list-template.types';
import { CreateListFromTemplateDto } from '../list-templates/dto/create-list-from-template.dto';
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
import { CreateListDto } from './dto/create-list.dto';
import { UpdateListDto } from './dto/update-list.dto';
import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity';
@Injectable()
export class ListsService {
constructor(
@InjectRepository(UserListEntity)
private readonly listsRepository: Repository<UserListEntity>,
@InjectRepository(UserListItemEntity)
private readonly listItemsRepository: Repository<UserListItemEntity>,
) {}
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
const list = this.listsRepository.create({
id: randomUUID(),
ownerId,
name: this.requireName(createDto.name),
description: this.normalizeOptionalText(createDto.description),
kind: this.normalizeKind(createDto.kind),
items: [],
});
return this.toUserList(await this.listsRepository.save(list));
}
async createListFromTemplate(
ownerId: string,
template: ListTemplate,
createDto: CreateListFromTemplateDto = {},
): Promise<UserList> {
if (template.ownerId !== ownerId) {
throw new ForbiddenException('List template belongs to another user.');
}
const list = this.listsRepository.create({
id: randomUUID(),
ownerId,
sourceTemplateId: template.id,
name: this.normalizeDerivedListName(createDto.name, template.name),
description:
createDto.description !== undefined
? this.normalizeOptionalText(createDto.description)
: template.description,
kind: template.kind,
items: template.items.map((item) =>
this.listItemsRepository.create({
id: randomUUID(),
sourceTemplateItemId: item.id,
title: item.title,
notes: item.notes,
quantity: item.quantity,
required: item.required,
checked: false,
position: item.position,
}),
),
});
return this.toUserList(await this.listsRepository.save(list));
}
async listLists(ownerId: string): Promise<UserList[]> {
const lists = await this.listsRepository.find({
where: { ownerId },
relations: { items: true },
order: { name: 'ASC', items: { position: 'ASC' } },
});
return lists.map((list) => this.toUserList(list));
}
async getList(ownerId: string, listId: string): Promise<UserList> {
return this.toUserList(await this.findOwnedList(ownerId, listId));
}
async updateList(
ownerId: string,
listId: string,
updateDto: UpdateListDto,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
if (updateDto.name !== undefined) {
list.name = this.requireName(updateDto.name);
}
if (updateDto.description !== undefined) {
list.description = this.normalizeOptionalText(updateDto.description);
}
if (updateDto.kind !== undefined) {
list.kind = this.normalizeKind(updateDto.kind);
}
return this.toUserList(await this.listsRepository.save(list));
}
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
const list = await this.findOwnedList(ownerId, listId);
await this.listsRepository.remove(list);
return { message: 'List deleted.' };
}
async addItem(
ownerId: string,
listId: string,
addDto: AddListItemDto,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const item = this.createListItem(addDto, list.items.length);
item.listId = list.id;
const savedItem = await this.listItemsRepository.save(item);
list.items.push(savedItem);
await this.listsRepository.save(list);
return this.getList(ownerId, listId);
}
async updateItem(
ownerId: string,
listId: string,
itemId: string,
updateDto: UpdateListItemDto,
actorName?: string,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const item = this.findListItem(list, itemId);
if (updateDto.title !== undefined) {
item.title = this.requireItemTitle(updateDto.title);
}
if (updateDto.notes !== undefined) {
item.notes = this.normalizeOptionalText(updateDto.notes);
}
if (updateDto.quantity !== undefined) {
item.quantity = this.normalizeQuantity(updateDto.quantity);
}
if (updateDto.required !== undefined) {
item.required = this.normalizeBoolean(updateDto.required, 'required');
}
if (updateDto.checked !== undefined) {
item.checked = this.normalizeBoolean(updateDto.checked, 'checked');
if (item.checked) {
item.checkedAt = new Date();
item.checkedByUserId = ownerId;
item.checkedByName = actorName ?? ownerId;
} else {
item.checkedAt = null;
item.checkedByUserId = null;
item.checkedByName = null;
}
}
await this.listItemsRepository.save(item);
await this.listsRepository.save(list);
return this.getList(ownerId, listId);
}
async deleteItem(
ownerId: string,
listId: string,
itemId: string,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const itemIndex = list.items.findIndex((item) => item.id === itemId);
if (itemIndex === -1) {
throw new NotFoundException('List item was not found.');
}
const [itemToDelete] = list.items.splice(itemIndex, 1);
await this.listItemsRepository.remove(itemToDelete);
list.items.forEach((item, index) => {
item.position = index;
});
await this.listItemsRepository.save(list.items);
await this.listsRepository.save(list);
return this.getList(ownerId, listId);
}
private async findOwnedList(
ownerId: string,
listId: string,
): Promise<UserListEntity> {
const list = await this.listsRepository.findOne({
where: { id: listId },
relations: { items: true },
order: { items: { position: 'ASC' } },
});
if (!list) {
throw new NotFoundException('List was not found.');
}
if (list.ownerId !== ownerId) {
throw new ForbiddenException('List belongs to another user.');
}
list.items = list.items ?? [];
return list;
}
private findListItem(
list: UserListEntity,
itemId: string,
): UserListItemEntity {
const item = list.items.find((listItem) => listItem.id === itemId);
if (!item) {
throw new NotFoundException('List item was not found.');
}
return item;
}
private createListItem(
itemDto: AddListItemDto,
position: number,
): UserListItemEntity {
return this.listItemsRepository.create({
id: randomUUID(),
title: this.requireItemTitle(itemDto.title),
notes: this.normalizeOptionalText(itemDto.notes),
quantity: this.normalizeQuantity(itemDto.quantity),
required:
itemDto.required === undefined
? true
: this.normalizeBoolean(itemDto.required, 'required'),
checked: false,
position,
});
}
private normalizeKind(kind?: ListTemplateKind): ListTemplateKind {
if (kind === undefined) {
return 'custom';
}
if (
kind !== 'packing' &&
kind !== 'shopping' &&
kind !== 'todo' &&
kind !== 'custom'
) {
throw new BadRequestException('List kind is invalid.');
}
return kind;
}
private requireName(name?: string): string {
const normalizedName = name?.trim();
if (!normalizedName) {
throw new BadRequestException('List name is required.');
}
return normalizedName;
}
private requireItemTitle(title?: string): string {
const normalizedTitle = title?.trim();
if (!normalizedTitle) {
throw new BadRequestException('List item title is required.');
}
return normalizedTitle;
}
private normalizeDerivedListName(name: string | undefined, fallback: string) {
const normalizedName = name?.trim();
return normalizedName || fallback;
}
private normalizeOptionalText(value?: string): string | undefined {
const normalizedValue = value?.trim();
return normalizedValue || undefined;
}
private normalizeQuantity(quantity?: number): number | undefined {
if (quantity === undefined) {
return undefined;
}
if (
typeof quantity !== 'number' ||
!Number.isFinite(quantity) ||
quantity <= 0
) {
throw new BadRequestException('Quantity must be greater than zero.');
}
return quantity;
}
private normalizeBoolean(value: boolean, fieldName: string): boolean {
if (typeof value !== 'boolean') {
throw new BadRequestException(`${fieldName} must be a boolean.`);
}
return value;
}
private toUserList(list: UserListEntity): UserList {
return {
id: list.id,
ownerId: list.ownerId,
sourceTemplateId: list.sourceTemplateId ?? undefined,
name: list.name,
description: list.description ?? undefined,
kind: list.kind,
items: (list.items ?? [])
.sort((left, right) => left.position - right.position)
.map((item) => this.toUserListItem(item)),
createdAt: this.toIsoString(list.createdAt),
updatedAt: this.toIsoString(list.updatedAt),
};
}
private toUserListItem(item: UserListItemEntity): UserListItem {
return {
id: item.id,
sourceTemplateItemId: item.sourceTemplateItemId ?? undefined,
title: item.title,
notes: item.notes ?? undefined,
quantity: item.quantity ?? undefined,
required: item.required,
checked: item.checked,
checkedAt: item.checkedAt ? this.toIsoString(item.checkedAt) : undefined,
checkedByUserId: item.checkedByUserId ?? undefined,
checkedByName: item.checkedByName ?? undefined,
position: item.position,
createdAt: this.toIsoString(item.createdAt),
updatedAt: this.toIsoString(item.updatedAt),
};
}
private toIsoString(value?: Date): string {
return (value ?? new Date()).toISOString();
}
}

View File

@@ -0,0 +1,72 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserListEntity } from './user-list.entity';
@Entity('user_list_items')
export class UserListItemEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
listId!: string;
@Column({ type: 'varchar', length: 36, nullable: true })
sourceTemplateItemId?: string | null;
@Column({ type: 'varchar', length: 220 })
title!: string;
@Column({ type: 'text', nullable: true })
notes?: string | null;
@Column({ type: 'float', nullable: true })
quantity?: number | null;
@Column({ type: 'boolean', default: true })
required!: boolean;
@Column({ type: 'boolean', default: false })
checked!: boolean;
@Column({ type: 'datetime', precision: 3, nullable: true })
checkedAt?: Date | null;
@Column({ type: 'varchar', length: 36, nullable: true })
checkedByUserId?: string | null;
@Column({ type: 'varchar', length: 320, nullable: true })
checkedByName?: string | null;
@Column({ type: 'int' })
position!: number;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@ManyToOne(() => UserListEntity, (list) => list.items, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'listId' })
list?: UserListEntity;
}

View File

@@ -0,0 +1,62 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserEntity } from '../auth/user.entity';
import { UserListItemEntity } from './user-list-item.entity';
import type { ListTemplateKind } from '../list-templates/list-template.types';
@Entity('user_lists')
export class UserListEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
ownerId!: string;
@Column({ type: 'varchar', length: 36, nullable: true })
sourceTemplateId?: string | null;
@Column({ type: 'varchar', length: 160 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string | null;
@Column({ type: 'varchar', length: 32, default: 'custom' })
kind!: ListTemplateKind;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@ManyToOne(() => UserEntity, (user) => user.lists, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'ownerId' })
owner?: UserEntity;
@OneToMany(() => UserListItemEntity, (item) => item.list, {
cascade: ['insert', 'update'],
})
items!: UserListItemEntity[];
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { AppEvents } from '../events/app-events';
import type { UserRegisteredEvent } from '../events/app-events';
import { MailService } from './mail.service';
@Injectable()
export class MailEventsListener {
constructor(private readonly mailService: MailService) {}
@OnEvent(AppEvents.UserRegistered)
handleUserRegistered(event: UserRegisteredEvent): void {
this.mailService.sendVerificationEmail(event.email, event.verificationUrl);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MailEventsListener } from './mail-events.listener';
import { MailService } from './mail.service';
@Module({
providers: [MailService, MailEventsListener],
exports: [MailService],
})
export class MailModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable, Logger } from '@nestjs/common';
import { SentEmail } from './mail.types';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private readonly sentEmails: SentEmail[] = [];
sendVerificationEmail(to: string, verificationUrl: string): void {
const email: SentEmail = {
to,
subject: 'Verify your Listify account',
text: `Please verify your account by opening this link: ${verificationUrl}`,
verificationUrl,
};
this.sentEmails.push(email);
this.logger.log(
`Verification email sent to ${to}: ${email.verificationUrl}`,
);
}
getSentEmails(): SentEmail[] {
return [...this.sentEmails];
}
}

View File

@@ -0,0 +1,6 @@
export interface SentEmail {
to: string;
subject: string;
text: string;
verificationUrl: string;
}

10
listify-api/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet());
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

View File

@@ -0,0 +1,117 @@
type WhereClause<T> = Partial<Record<keyof T, unknown>>;
export class InMemoryRepository<T extends object> {
private readonly records = new Map<string, T>();
constructor(
private readonly keyFields: Array<keyof T & string> = [
'id' as keyof T & string,
'jti' as keyof T & string,
'ownerId' as keyof T & string,
],
) {}
create(entity: Partial<T>): T {
return entity as T;
}
async save(entityOrEntities: T | T[]): Promise<T | T[]> {
if (Array.isArray(entityOrEntities)) {
return Promise.all(entityOrEntities.map((entity) => this.saveOne(entity)));
}
return this.saveOne(entityOrEntities);
}
async find(options: { where?: WhereClause<T>; order?: unknown } = {}): Promise<T[]> {
const records = [...this.records.values()].filter((record) =>
this.matchesWhere(record, options.where),
);
return this.applyOrder(records, options.order);
}
async findOne(options: { where?: WhereClause<T>; order?: unknown }): Promise<T | null> {
const [record] = await this.find(options);
return record ?? null;
}
async delete(criteria: WhereClause<T>): Promise<void> {
for (const [key, record] of this.records.entries()) {
if (this.matchesWhere(record, criteria)) {
this.records.delete(key);
}
}
}
async remove(entityOrEntities: T | T[]): Promise<T | T[]> {
if (Array.isArray(entityOrEntities)) {
entityOrEntities.forEach((entity) => this.records.delete(this.keyOf(entity)));
return entityOrEntities;
}
this.records.delete(this.keyOf(entityOrEntities));
return entityOrEntities;
}
private saveOne(entity: T): T {
const now = new Date();
const timestamped = entity as T & { createdAt?: Date; updatedAt?: Date };
timestamped.createdAt ??= now;
timestamped.updatedAt = now;
this.records.set(this.keyOf(entity), entity);
return entity;
}
private keyOf(entity: T): string {
for (const keyField of this.keyFields) {
const value = (entity as Record<string, unknown>)[keyField];
if (typeof value === 'string' && value) {
return value;
}
}
throw new Error('Entity has no supported in-memory repository key.');
}
private matchesWhere(record: T, where?: WhereClause<T>): boolean {
if (!where) {
return true;
}
return Object.entries(where).every(([key, value]) => {
return (record as Record<string, unknown>)[key] === value;
});
}
private applyOrder(records: T[], order: unknown): T[] {
const sortedRecords = [...records];
const typedOrder = order as { name?: 'ASC' | 'DESC'; items?: { position?: 'ASC' | 'DESC' } };
if (typedOrder?.name) {
sortedRecords.sort((left, right) => {
const leftName = String((left as Record<string, unknown>)['name'] ?? '');
const rightName = String((right as Record<string, unknown>)['name'] ?? '');
return typedOrder.name === 'DESC'
? rightName.localeCompare(leftName)
: leftName.localeCompare(rightName);
});
}
if (typedOrder?.items?.position) {
for (const record of sortedRecords) {
const items = (record as { items?: Array<{ position: number }> }).items;
items?.sort((left, right) =>
typedOrder.items?.position === 'DESC'
? right.position - left.position
: left.position - right.position,
);
}
}
return sortedRecords;
}
}

View File

@@ -0,0 +1,263 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { MailService } from './../src/mail/mail.service';
interface AuthResponseBody {
accessToken?: string;
refreshToken?: string;
user: {
id?: string;
email: string;
verified: boolean;
};
}
interface ListTemplateResponseBody {
id: string;
name: string;
kind: string;
items: {
id: string;
title: string;
checked?: boolean;
}[];
}
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
let mailService: MailService;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
mailService = moduleFixture.get<MailService>(MailService);
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
it('/auth register, verify and login', async () => {
const registerResponse = await request(app.getHttpServer())
.post('/auth/register')
.send({
email: 'user@example.com',
password: 'password123',
})
.expect(201);
const registerBody = registerResponse.body as unknown as AuthResponseBody;
expect(registerBody.user.email).toBe('user@example.com');
expect(registerBody.user.verified).toBe(false);
await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'user@example.com',
password: 'password123',
})
.expect(401);
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
const verifyResponse = await request(app.getHttpServer())
.get('/auth/verify-email')
.query({ token })
.expect(200);
const verifyBody = verifyResponse.body as unknown as AuthResponseBody;
expect(verifyBody.user.verified).toBe(true);
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email: 'user@example.com',
password: 'password123',
})
.expect(200);
const loginBody = loginResponse.body as unknown as AuthResponseBody;
expect(loginBody.accessToken).toBeDefined();
expect(loginBody.refreshToken).toBeDefined();
expect(loginBody.user.email).toBe('user@example.com');
const refreshResponse = await request(app.getHttpServer())
.post('/auth/refresh')
.send({
refreshToken: loginBody.refreshToken,
})
.expect(201);
const refreshBody = refreshResponse.body as unknown as AuthResponseBody;
expect(refreshBody.accessToken).toBeDefined();
expect(refreshBody.refreshToken).toBeDefined();
expect(refreshBody.refreshToken).not.toBe(loginBody.refreshToken);
await request(app.getHttpServer())
.post('/auth/refresh')
.send({
refreshToken: loginBody.refreshToken,
})
.expect(401);
});
it('/list-templates creates, updates and uses a template', async () => {
const accessToken = await registerVerifiedUserAndGetAccessToken(
'template-user@example.com',
);
const initialTemplatesResponse = await request(app.getHttpServer())
.get('/list-templates')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const initialTemplates =
initialTemplatesResponse.body as unknown as ListTemplateResponseBody[];
expect(initialTemplates).toHaveLength(3);
expect(initialTemplates.map((template) => template.name)).toContain(
'Urlaub',
);
const createTemplateResponse = await request(app.getHttpServer())
.post('/list-templates')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Urlaub',
kind: 'packing',
items: [{ title: 'Pass' }, { title: 'Tickets' }],
})
.expect(201);
const createdTemplate =
createTemplateResponse.body as unknown as ListTemplateResponseBody;
expect(createdTemplate.name).toBe('Urlaub');
expect(createdTemplate.items).toHaveLength(2);
const updateTemplateResponse = await request(app.getHttpServer())
.patch(`/list-templates/${createdTemplate.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Sommerurlaub',
})
.expect(200);
const updatedTemplate =
updateTemplateResponse.body as unknown as ListTemplateResponseBody;
expect(updatedTemplate.name).toBe('Sommerurlaub');
const createListResponse = await request(app.getHttpServer())
.post(`/list-templates/${createdTemplate.id}/lists`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Sommerurlaub 2026',
})
.expect(201);
const createdList =
createListResponse.body as unknown as ListTemplateResponseBody;
expect(createdList.name).toBe('Sommerurlaub 2026');
expect(createdList.items[0].title).toBe('Pass');
expect(createdList.items[0].checked).toBe(false);
});
it('/lists creates, updates and reads a concrete list', async () => {
const accessToken = await registerVerifiedUserAndGetAccessToken(
'list-user@example.com',
);
const createListResponse = await request(app.getHttpServer())
.post('/lists')
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'Wocheneinkauf',
kind: 'shopping',
})
.expect(201);
const createdList =
createListResponse.body as unknown as ListTemplateResponseBody;
expect(createdList.name).toBe('Wocheneinkauf');
expect(createdList.items).toHaveLength(0);
const addItemResponse = await request(app.getHttpServer())
.post(`/lists/${createdList.id}/items`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
title: 'Milch',
quantity: 2,
})
.expect(201);
const listWithItem =
addItemResponse.body as unknown as ListTemplateResponseBody;
expect(listWithItem.items).toHaveLength(1);
expect(listWithItem.items[0].title).toBe('Milch');
await request(app.getHttpServer())
.patch(`/lists/${createdList.id}/items/${listWithItem.items[0].id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
checked: true,
})
.expect(200);
const getListResponse = await request(app.getHttpServer())
.get(`/lists/${createdList.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const fetchedList =
getListResponse.body as unknown as ListTemplateResponseBody;
expect(fetchedList.items[0].checked).toBe(true);
});
async function registerVerifiedUserAndGetAccessToken(
email: string,
): Promise<string> {
await request(app.getHttpServer())
.post('/auth/register')
.send({
email,
password: 'password123',
})
.expect(201);
const verificationUrl =
mailService.getSentEmails()[mailService.getSentEmails().length - 1]
.verificationUrl;
const token = new URL(verificationUrl).searchParams.get('token');
await request(app.getHttpServer())
.get('/auth/verify-email')
.query({ token })
.expect(200);
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({
email,
password: 'password123',
})
.expect(200);
const loginBody = loginResponse.body as unknown as AuthResponseBody;
expect(loginBody.accessToken).toBeDefined();
return loginBody.accessToken ?? '';
}
afterEach(async () => {
await app.close();
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
listify-api/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
listify-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
listify-client/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
listify-client/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
listify-client/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

59
listify-client/README.md Normal file
View File

@@ -0,0 +1,59 @@
# ListifyClient
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.6.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,82 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "2085696e-41a3-455d-be66-774ca34f4584"
},
"newProjectRoot": "projects",
"projects": {
"listify-client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "listify-client:build:production"
},
"development": {
"buildTarget": "listify-client:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

8725
listify-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"name": "listify-client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.12.1",
"dependencies": {
"@angular/cdk": "^21.2.14",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.14",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

View File

@@ -0,0 +1,10 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"pathRewrite": {
"^/api": ""
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,22 @@
<section class="account-page">
<mat-card class="account-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Willkommen, {{ auth.user()?.name || auth.user()?.email }}</mat-card-title>
<mat-card-subtitle>{{ auth.user()?.email }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="status-row">
<mat-icon aria-hidden="true">verified_user</mat-icon>
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
</div>
</mat-card-content>
<mat-card-actions align="end">
<button mat-stroked-button type="button" (click)="logout()">
<mat-icon aria-hidden="true">logout</mat-icon>
Logout
</button>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,30 @@
.account-page {
min-height: inherit;
display: grid;
align-items: start;
padding: 1rem;
}
.account-card {
width: min(100%, 520px);
border-radius: 8px;
}
.status-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
color: var(--mat-sys-on-surface-variant);
}
.status-row mat-icon {
color: var(--mat-sys-primary);
}
@media (min-width: 600px) {
.account-page {
place-items: center;
padding: 2rem 1rem;
}
}

View File

@@ -0,0 +1,22 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-account',
imports: [MatButtonModule, MatCardModule, MatIconModule],
templateUrl: './account.component.html',
styleUrl: './account.component.scss',
})
export class AccountComponent {
protected readonly auth = inject(AuthService);
private readonly router = inject(Router);
logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');
}
}

View File

@@ -0,0 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes)
]
};

View File

@@ -0,0 +1,130 @@
<mat-toolbar class="app-toolbar">
@if (auth.isAuthenticated()) {
<button
mat-icon-button
type="button"
class="menu-button"
aria-label="Menue oeffnen"
(click)="toggleSidebar()"
>
<mat-icon aria-hidden="true">menu</mat-icon>
</button>
}
<a class="brand" [routerLink]="auth.isAuthenticated() ? '/lists' : '/login'" aria-label="Listify Startseite">
<mat-icon aria-hidden="true">checklist</mat-icon>
<span>Listify</span>
</a>
<span class="spacer"></span>
@if (auth.isAuthenticated()) {
<span class="toolbar-user">{{ auth.user()?.name || auth.user()?.email }}</span>
} @else {
<a
mat-button
routerLink="/login"
routerLinkActive="active-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">login</mat-icon>
Login
</a>
<a
mat-flat-button
routerLink="/register"
routerLinkActive="active-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">person_add</mat-icon>
Registrieren
</a>
}
</mat-toolbar>
@if (auth.isAuthenticated()) {
<mat-sidenav-container class="shell">
<mat-sidenav
class="sidebar"
[mode]="isCompact() ? 'over' : 'side'"
[opened]="!isCompact() || sidebarOpened()"
fixedInViewport
[fixedTopGap]="isCompact() ? 56 : 64"
>
<nav aria-label="Hauptnavigation">
<mat-nav-list>
<a
mat-list-item
routerLink="/templates"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">dashboard_customize</mat-icon>
<span matListItemTitle>Templates</span>
</a>
<a
mat-list-item
routerLink="/lists"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">format_list_bulleted</mat-icon>
<span matListItemTitle>Listen</span>
</a>
<a
mat-list-item
routerLink="/account"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">account_circle</mat-icon>
<span matListItemTitle>Account</span>
</a>
</mat-nav-list>
</nav>
</mat-sidenav>
<mat-sidenav-content class="shell-content">
<main class="app-main">
<router-outlet />
</main>
</mat-sidenav-content>
</mat-sidenav-container>
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
<a
class="bottom-nav-link"
routerLink="/templates"
routerLinkActive="active-bottom-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">dashboard_customize</mat-icon>
<span>Templates</span>
</a>
<a
class="bottom-nav-link"
routerLink="/lists"
routerLinkActive="active-bottom-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">format_list_bulleted</mat-icon>
<span>Listen</span>
</a>
<a
class="bottom-nav-link"
routerLink="/account"
routerLinkActive="active-bottom-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">account_circle</mat-icon>
<span>Account</span>
</a>
</nav>
} @else {
<main class="app-main auth-main">
<router-outlet />
</main>
}

View File

@@ -0,0 +1,38 @@
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
import { LoginComponent } from './auth/login/login.component';
import { RegisterComponent } from './auth/register/register.component';
import { AccountComponent } from './account/account.component';
import { VerifyEmailComponent } from './auth/verify-email/verify-email.component';
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
import { ListsComponent } from './lists/lists.component';
import { TemplatesComponent } from './templates/templates.component';
import { TemplateDetailComponent } from './templates/template-detail/template-detail.component';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'login' },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'verify-email', component: VerifyEmailComponent },
{
path: 'auth',
children: [{ path: 'verify-email', component: VerifyEmailComponent }],
},
{ path: 'templates', component: TemplatesComponent, canActivate: [authGuard] },
{
path: 'templates/new',
component: TemplateDetailComponent,
canActivate: [authGuard],
},
{
path: 'templates/:templateId',
component: TemplateDetailComponent,
canActivate: [authGuard],
},
{ path: 'lists', component: ListsComponent, canActivate: [authGuard] },
{ path: 'lists/new', component: ListDetailComponent, canActivate: [authGuard] },
{ path: 'lists/:listId', component: ListDetailComponent, canActivate: [authGuard] },
{ path: 'listen', redirectTo: 'lists' },
{ path: 'account', component: AccountComponent, canActivate: [authGuard] },
{ path: '**', redirectTo: 'login' },
];

View File

@@ -0,0 +1,198 @@
:host {
display: block;
min-height: 100dvh;
background:
linear-gradient(135deg, rgba(20, 105, 84, 0.12), transparent 36%),
linear-gradient(315deg, rgba(123, 92, 40, 0.12), transparent 34%),
var(--mat-sys-surface);
}
.app-toolbar {
position: sticky;
top: 0;
z-index: 10;
height: 56px;
min-height: 56px;
padding-inline: 0.75rem;
border-bottom: 1px solid var(--mat-sys-outline-variant);
background: color-mix(in srgb, var(--mat-sys-surface) 92%, transparent);
color: var(--mat-sys-on-surface);
backdrop-filter: blur(14px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
font-weight: 600;
min-width: 0;
text-decoration: none;
}
.brand mat-icon {
color: var(--mat-sys-primary);
}
.menu-button {
flex: 0 0 auto;
margin-right: 0.125rem;
}
.spacer {
flex: 1 1 auto;
}
.toolbar-user {
overflow: hidden;
max-width: 42vw;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8125rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-toolbar a[mat-button],
.app-toolbar a[mat-flat-button] {
margin-left: 0.25rem;
}
.active-link {
color: var(--mat-sys-primary);
}
.shell {
min-height: calc(100dvh - 56px);
background: transparent;
}
.sidebar {
width: min(82vw, 280px);
border-right: 1px solid var(--mat-sys-outline-variant);
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 96%, white);
}
.sidebar nav {
padding: 0.75rem;
}
.sidebar a[mat-list-item] {
margin-bottom: 0.25rem;
border-radius: 8px;
}
.active-nav-link {
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color: var(--mat-sys-primary);
}
.shell-content {
min-height: calc(100dvh - 56px);
}
.app-main {
min-height: calc(100dvh - 56px);
}
.shell .app-main {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
.auth-main {
display: block;
}
.bottom-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--mat-sys-outline-variant);
background: color-mix(in srgb, var(--mat-sys-surface) 96%, transparent);
backdrop-filter: blur(14px);
}
.bottom-nav-link {
display: grid;
justify-items: center;
gap: 0.125rem;
min-width: 0;
min-height: 56px;
padding: 0.35rem 0.25rem;
border-radius: 8px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.75rem;
line-height: 1;
text-decoration: none;
}
.bottom-nav-link mat-icon {
width: 24px;
height: 24px;
font-size: 24px;
}
.bottom-nav-link span {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.active-bottom-link {
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color: var(--mat-sys-primary);
}
@media (max-width: 420px) {
.app-toolbar a[mat-button],
.app-toolbar a[mat-flat-button] {
min-width: 0;
padding-inline: 0.5rem;
}
.app-toolbar a[mat-flat-button] mat-icon,
.app-toolbar a[mat-button] mat-icon {
display: none;
}
}
@media (min-width: 801px) {
.app-toolbar {
height: 64px;
min-height: 64px;
padding-inline: 1rem;
}
.menu-button {
display: none;
}
.toolbar-user {
max-width: min(40vw, 360px);
font-size: 0.9rem;
}
.shell,
.shell-content,
.app-main {
min-height: calc(100dvh - 64px);
}
.shell .app-main {
padding-bottom: 0;
}
.sidebar {
width: 248px;
}
.bottom-nav {
display: none;
}
}

View File

@@ -0,0 +1,25 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render brand', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.brand')?.textContent).toContain('Listify');
});
});

View File

@@ -0,0 +1,47 @@
import { Component, inject, signal } from '@angular/core';
import { BreakpointObserver } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { map } from 'rxjs';
import { AuthService } from './auth/auth.service';
@Component({
selector: 'app-root',
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
MatButtonModule,
MatIconModule,
MatListModule,
MatSidenavModule,
MatToolbarModule,
],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
protected readonly auth = inject(AuthService);
private readonly breakpointObserver = inject(BreakpointObserver);
protected readonly isCompact = toSignal(
this.breakpointObserver.observe('(max-width: 800px)').pipe(map((state) => state.matches)),
{ initialValue: false },
);
protected readonly sidebarOpened = signal(false);
protected toggleSidebar(): void {
this.sidebarOpened.update((opened) => !opened);
}
protected closeSidebarOnCompact(): void {
if (this.isCompact()) {
this.sidebarOpened.set(false);
}
}
}

View File

@@ -0,0 +1,78 @@
.auth-page {
min-height: inherit;
display: grid;
align-items: start;
padding: 1rem;
}
.auth-card {
width: min(100%, 440px);
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 94%, white);
}
.auth-card mat-card-header {
padding-bottom: 0.75rem;
}
.auth-form {
display: grid;
gap: 0.65rem;
padding-top: 0.75rem;
}
.auth-form mat-form-field {
width: 100%;
}
.auth-form button[type='submit'] {
min-height: 48px;
}
.auth-form mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.verify-card mat-card-content {
padding-top: 1rem;
}
.verification-state {
display: grid;
justify-items: center;
gap: 1rem;
min-height: 132px;
padding: 1rem 0.5rem;
text-align: center;
color: var(--mat-sys-on-surface-variant);
}
.verification-state p {
margin: 0;
}
.verification-state .state-icon {
width: 44px;
height: 44px;
font-size: 44px;
}
.verification-state.success .state-icon {
color: var(--mat-sys-primary);
}
.verification-state.error .state-icon {
color: var(--mat-sys-error);
}
@media (min-width: 481px) {
.auth-page {
place-items: center;
padding: 2rem 1rem;
}
.auth-form {
gap: 0.75rem;
}
}

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated() ? true : router.parseUrl('/login');
};

View File

@@ -0,0 +1,71 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> => {
const auth = inject(AuthService);
const router = inject(Router);
const authenticatedRequest = withAccessToken(request, auth.accessToken());
return next(authenticatedRequest).pipe(
catchError((error: unknown) => {
if (!shouldRefresh(request, error)) {
return throwError(() => error);
}
return auth.refreshSession().pipe(
switchMap((response) =>
next(withAccessToken(request, response.accessToken)),
),
catchError((refreshError: unknown) => {
auth.logout();
void router.navigateByUrl('/login');
return throwError(() => refreshError);
}),
);
}),
);
};
function withAccessToken(
request: HttpRequest<unknown>,
accessToken: string | null,
): HttpRequest<unknown> {
if (!accessToken || !isApiRequest(request) || isAuthRequest(request)) {
return request;
}
return request.clone({
setHeaders: {
Authorization: `Bearer ${accessToken}`,
},
});
}
function shouldRefresh(request: HttpRequest<unknown>, error: unknown): boolean {
return (
isApiRequest(request) &&
!isAuthRequest(request) &&
error instanceof HttpErrorResponse &&
error.status === 401
);
}
function isApiRequest(request: HttpRequest<unknown>): boolean {
return request.url.startsWith('/api/');
}
function isAuthRequest(request: HttpRequest<unknown>): boolean {
return request.url.startsWith('/api/auth/');
}

View File

@@ -0,0 +1,31 @@
export interface PublicUser {
id: string;
email: string;
name?: string;
verified: boolean;
}
export interface AuthTokenResponse {
accessToken: string;
refreshToken: string;
user: PublicUser;
}
export interface RegisterResponse {
message: string;
user: PublicUser;
}
export interface VerifyEmailResponse {
message: string;
user: PublicUser;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest extends LoginRequest {
name?: string;
}

View File

@@ -0,0 +1,102 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
import {
AuthTokenResponse,
LoginRequest,
PublicUser,
RegisterRequest,
RegisterResponse,
VerifyEmailResponse,
} from './auth.models';
const ACCESS_TOKEN_KEY = 'listify.accessToken';
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
const USER_KEY = 'listify.user';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/auth';
private readonly userSignal = signal<PublicUser | null>(this.readStoredUser());
private refreshRequest$: Observable<AuthTokenResponse> | null = null;
readonly user = this.userSignal.asReadonly();
readonly isAuthenticated = computed(() => Boolean(this.userSignal()));
login(credentials: LoginRequest): Observable<AuthTokenResponse> {
return this.http
.post<AuthTokenResponse>(`${this.apiUrl}/login`, credentials)
.pipe(tap((response) => this.storeSession(response)));
}
register(data: RegisterRequest): Observable<RegisterResponse> {
return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data);
}
verifyEmail(token: string): Observable<VerifyEmailResponse> {
const params = new HttpParams().set('token', token);
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
}
accessToken(): string | null {
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
}
refreshToken(): string | null {
return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null;
}
refreshSession(): Observable<AuthTokenResponse> {
const refreshToken = this.refreshToken();
if (!refreshToken) {
return throwError(() => new Error('Refresh token is missing.'));
}
this.refreshRequest$ ??= this.http
.post<AuthTokenResponse>(`${this.apiUrl}/refresh`, { refreshToken })
.pipe(
tap((response) => this.storeSession(response)),
finalize(() => {
this.refreshRequest$ = null;
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
return this.refreshRequest$;
}
logout(): void {
this.storage?.removeItem(ACCESS_TOKEN_KEY);
this.storage?.removeItem(REFRESH_TOKEN_KEY);
this.storage?.removeItem(USER_KEY);
this.userSignal.set(null);
}
private storeSession(response: AuthTokenResponse): void {
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
this.storage?.setItem(USER_KEY, JSON.stringify(response.user));
this.userSignal.set(response.user);
}
private readStoredUser(): PublicUser | null {
const rawUser = this.storage?.getItem(USER_KEY);
if (!rawUser) {
return null;
}
try {
return JSON.parse(rawUser) as PublicUser;
} catch {
this.storage?.removeItem(USER_KEY);
return null;
}
}
private get storage(): Storage | null {
return typeof window === 'undefined' ? null : window.localStorage;
}
}

View File

@@ -0,0 +1,24 @@
import { HttpErrorResponse } from '@angular/common/http';
type ApiErrorBody = {
message?: string | string[];
error?: string;
};
export function getAuthErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const body = error.error as ApiErrorBody | string | undefined;
if (typeof body === 'string') {
return body;
}
if (Array.isArray(body?.message)) {
return body.message.join(' ');
}
return body?.message ?? body?.error ?? 'Die Anfrage konnte nicht verarbeitet werden.';
}
return 'Die Anfrage konnte nicht verarbeitet werden.';
}

View File

@@ -0,0 +1,60 @@
<section class="auth-page">
<mat-card class="auth-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Login</mat-card-title>
<mat-card-subtitle>Mit deinem Listify-Konto anmelden</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form">
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-icon matSuffix aria-hidden="true">mail</mat-icon>
@if (form.controls.email.hasError('required')) {
<mat-error>E-Mail ist erforderlich.</mat-error>
} @else if (form.controls.email.hasError('email')) {
<mat-error>Bitte gib eine gueltige E-Mail ein.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
matInput
[type]="hidePassword ? 'password' : 'text'"
formControlName="password"
autocomplete="current-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="hidePassword ? 'Passwort anzeigen' : 'Passwort verbergen'"
(click)="hidePassword = !hidePassword"
>
<mat-icon aria-hidden="true">{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
@if (form.controls.password.hasError('required')) {
<mat-error>Passwort ist erforderlich.</mat-error>
} @else if (form.controls.password.hasError('minlength')) {
<mat-error>Mindestens 8 Zeichen.</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="submit" [disabled]="loading">
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">login</mat-icon>
}
Einloggen
</button>
</form>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/register">Konto erstellen</a>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,64 @@
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
@Component({
selector: 'app-login',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './login.component.html',
styleUrl: '../auth-page.scss',
})
export class LoginComponent {
private readonly auth = inject(AuthService);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
protected readonly form = this.formBuilder.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
protected loading = false;
protected hidePassword = true;
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading = true;
this.auth
.login(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: () => {
this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 });
void this.router.navigateByUrl('/account');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
}

View File

@@ -0,0 +1,66 @@
<section class="auth-page">
<mat-card class="auth-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Registrieren</mat-card-title>
<mat-card-subtitle>Neues Listify-Konto anlegen</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput type="text" formControlName="name" autocomplete="name" />
<mat-icon matSuffix aria-hidden="true">badge</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-icon matSuffix aria-hidden="true">mail</mat-icon>
@if (form.controls.email.hasError('required')) {
<mat-error>E-Mail ist erforderlich.</mat-error>
} @else if (form.controls.email.hasError('email')) {
<mat-error>Bitte gib eine gueltige E-Mail ein.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
matInput
[type]="hidePassword ? 'password' : 'text'"
formControlName="password"
autocomplete="new-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="hidePassword ? 'Passwort anzeigen' : 'Passwort verbergen'"
(click)="hidePassword = !hidePassword"
>
<mat-icon aria-hidden="true">{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
@if (form.controls.password.hasError('required')) {
<mat-error>Passwort ist erforderlich.</mat-error>
} @else if (form.controls.password.hasError('minlength')) {
<mat-error>Mindestens 8 Zeichen.</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="submit" [disabled]="loading">
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">person_add</mat-icon>
}
Registrieren
</button>
</form>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/login">Zum Login</a>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,65 @@
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
@Component({
selector: 'app-register',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './register.component.html',
styleUrl: '../auth-page.scss',
})
export class RegisterComponent {
private readonly auth = inject(AuthService);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
protected readonly form = this.formBuilder.group({
name: [''],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
protected loading = false;
protected hidePassword = true;
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading = true;
this.auth
.register(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (response) => {
this.snackBar.open(response.message, 'OK', { duration: 5000 });
void this.router.navigateByUrl('/login');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
}

View File

@@ -0,0 +1,34 @@
<section class="auth-page">
<mat-card class="auth-card verify-card" appearance="outlined">
<mat-card-header>
<mat-card-title>E-Mail-Verifikation</mat-card-title>
<mat-card-subtitle>{{ email() || 'Listify-Konto bestaetigen' }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="verification-state" [class.success]="state() === 'success'" [class.error]="state() === 'error' || state() === 'missing-token'">
@if (state() === 'loading') {
<mat-progress-spinner mode="indeterminate" diameter="44" />
} @else if (state() === 'success') {
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
} @else {
<mat-icon class="state-icon" aria-hidden="true">error</mat-icon>
}
<p>{{ message() }}</p>
</div>
</mat-card-content>
<mat-card-actions align="end">
@if (state() === 'success') {
<a mat-flat-button routerLink="/login">
<mat-icon aria-hidden="true">login</mat-icon>
Zum Login
</a>
} @else if (state() !== 'loading') {
<a mat-button routerLink="/register">Neu registrieren</a>
<a mat-flat-button routerLink="/login">Zum Login</a>
}
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,53 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
@Component({
selector: 'app-verify-email',
imports: [
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
templateUrl: './verify-email.component.html',
styleUrl: '../auth-page.scss',
})
export class VerifyEmailComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
protected readonly state = signal<VerificationState>('loading');
protected readonly message = signal('E-Mail wird bestaetigt.');
protected readonly email = signal<string | null>(null);
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token');
if (!token) {
this.state.set('missing-token');
this.message.set('Der Verifikationslink enthaelt keinen Token.');
return;
}
this.auth.verifyEmail(token).subscribe({
next: (response) => {
this.email.set(response.user.email);
this.message.set(response.message);
this.state.set('success');
},
error: (error: unknown) => {
this.message.set(getAuthErrorMessage(error));
this.state.set('error');
},
});
}
}

View File

@@ -0,0 +1,149 @@
<section class="list-detail-page">
<header class="detail-header">
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToLists()">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
<div>
<h1>{{ list()?.name || (isCreateMode() ? 'Neue Liste' : 'Liste') }}</h1>
@if (isCreateMode()) {
<p>Liste anlegen</p>
} @else if (list()) {
<p>{{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt</p>
}
</div>
</header>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="40" />
<h2>Liste wird geladen</h2>
</mat-card-content>
</mat-card>
} @else if (errorMessage()) {
<mat-card class="state-card error-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">error</mat-icon>
<h2>Liste konnte nicht geladen werden</h2>
<p>{{ errorMessage() }}</p>
<button mat-stroked-button type="button" (click)="loadList()">
<mat-icon aria-hidden="true">refresh</mat-icon>
Erneut laden
</button>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="editor-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Details</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="listForm" class="list-form" (ngSubmit)="saveList()">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
<input matInput formControlName="name" autocomplete="off" />
@if (listForm.controls.name.hasError('required')) {
<mat-error>Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Beschreibung</mat-label>
<textarea matInput formControlName="description" rows="4"></textarea>
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">save</mat-icon>
}
{{ isCreateMode() ? 'Liste anlegen' : 'Speichern' }}
</button>
</form>
</mat-card-content>
</mat-card>
<mat-card class="items-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Items</mat-card-title>
<mat-card-subtitle>
@if (canEditItems()) {
{{ list()?.items?.length || 0 }} Eintraege
} @else {
Nach dem Speichern verfuegbar
}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="itemForm" class="item-form" (ngSubmit)="addItem()">
<mat-form-field appearance="outline">
<mat-label>Neues Item</mat-label>
<input matInput formControlName="title" autocomplete="off" [disabled]="!canEditItems()" />
@if (itemForm.controls.title.hasError('required')) {
<mat-error>Item-Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-checkbox formControlName="required">Pflicht</mat-checkbox>
<button mat-flat-button type="submit" [disabled]="addingItem() || !canEditItems()">
@if (addingItem()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">add</mat-icon>
}
Hinzufuegen
</button>
</form>
@if (!canEditItems()) {
<div class="inline-empty">
<mat-icon aria-hidden="true">save</mat-icon>
<span>Speichere die Liste, bevor du Items hinzufuegst.</span>
</div>
} @else if (list()?.items?.length) {
<ul class="check-items">
@for (item of list()!.items; track item.id) {
<li [class.checked]="item.checked">
<mat-checkbox
[checked]="item.checked"
[disabled]="updatingItemId() === item.id"
(change)="toggleItem(item, $event.checked)"
>
<span class="item-title">{{ item.title }}</span>
</mat-checkbox>
@if (updatingItemId() === item.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
}
@if (item.checked && item.checkedAt && item.checkedByName) {
<div class="check-meta">
<mat-icon aria-hidden="true">verified</mat-icon>
<span>
Abgehakt von {{ item.checkedByName }} am
{{ item.checkedAt | date: 'dd.MM.yyyy, HH:mm' }}
</span>
</div>
}
</li>
}
</ul>
} @else {
<div class="inline-empty">
<mat-icon aria-hidden="true">playlist_add</mat-icon>
<span>Noch keine Items.</span>
</div>
}
</mat-card-content>
</mat-card>
<a mat-button routerLink="/lists" class="secondary-back">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
Zur Listenuebersicht
</a>
}
</section>

Some files were not shown because too many files have changed in this diff Show More