Initial
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
38
Dockerfile
Normal 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
9
docker-compose.local.yml
Normal 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
31
docker/nginx.conf
Normal 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
24
docker/start.sh
Normal 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
|
||||
16
listify-api/.env.docker.example
Normal file
16
listify-api/.env.docker.example
Normal 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
13
listify-api/.env.example
Normal 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
56
listify-api/.gitignore
vendored
Normal 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
4
listify-api/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
98
listify-api/README.md
Normal file
98
listify-api/README.md
Normal 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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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
80
listify-api/ROADMAP.md
Normal 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.
|
||||
35
listify-api/eslint.config.mjs
Normal file
35
listify-api/eslint.config.mjs
Normal 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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
8
listify-api/nest-cli.json
Normal file
8
listify-api/nest-cli.json
Normal 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
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
84
listify-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
listify-api/src/app.controller.spec.ts
Normal file
22
listify-api/src/app.controller.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
listify-api/src/app.controller.ts
Normal file
12
listify-api/src/app.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
42
listify-api/src/app.module.ts
Normal file
42
listify-api/src/app.module.ts
Normal 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'))
|
||||
}
|
||||
}
|
||||
8
listify-api/src/app.service.ts
Normal file
8
listify-api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
39
listify-api/src/auth/auth.controller.ts
Normal file
39
listify-api/src/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
listify-api/src/auth/auth.module.ts
Normal file
16
listify-api/src/auth/auth.module.ts
Normal 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 {}
|
||||
194
listify-api/src/auth/auth.service.spec.ts
Normal file
194
listify-api/src/auth/auth.service.spec.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
333
listify-api/src/auth/auth.service.ts
Normal file
333
listify-api/src/auth/auth.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
37
listify-api/src/auth/auth.types.ts
Normal file
37
listify-api/src/auth/auth.types.ts
Normal 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;
|
||||
}
|
||||
4
listify-api/src/auth/dto/login.dto.ts
Normal file
4
listify-api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export class LoginDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
3
listify-api/src/auth/dto/refresh-token.dto.ts
Normal file
3
listify-api/src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class RefreshTokenDto {
|
||||
refreshToken?: string;
|
||||
}
|
||||
5
listify-api/src/auth/dto/register.dto.ts
Normal file
5
listify-api/src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class RegisterDto {
|
||||
email?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
}
|
||||
113
listify-api/src/auth/jwt-auth.guard.spec.ts
Normal file
113
listify-api/src/auth/jwt-auth.guard.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
42
listify-api/src/auth/jwt-auth.guard.ts
Normal file
42
listify-api/src/auth/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
48
listify-api/src/auth/refresh-token.entity.ts
Normal file
48
listify-api/src/auth/refresh-token.entity.ts
Normal 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;
|
||||
}
|
||||
59
listify-api/src/auth/user.entity.ts
Normal file
59
listify-api/src/auth/user.entity.ts
Normal 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[];
|
||||
}
|
||||
31
listify-api/src/database/data-source.ts
Normal file
31
listify-api/src/database/data-source.ts
Normal 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'],
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
}
|
||||
8
listify-api/src/events/app-events.ts
Normal file
8
listify-api/src/events/app-events.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const AppEvents = {
|
||||
UserRegistered: 'user.registered',
|
||||
} as const;
|
||||
|
||||
export interface UserRegisteredEvent {
|
||||
email: string;
|
||||
verificationUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class CreateListFromTemplateDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
17
listify-api/src/list-templates/dto/list-template-item.dto.ts
Normal file
17
listify-api/src/list-templates/dto/list-template-item.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
57
listify-api/src/list-templates/list-template-item.entity.ts
Normal file
57
listify-api/src/list-templates/list-template-item.entity.ts
Normal 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;
|
||||
}
|
||||
59
listify-api/src/list-templates/list-template.entity.ts
Normal file
59
listify-api/src/list-templates/list-template.entity.ts
Normal 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[];
|
||||
}
|
||||
51
listify-api/src/list-templates/list-template.types.ts
Normal file
51
listify-api/src/list-templates/list-template.types.ts
Normal 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;
|
||||
}
|
||||
170
listify-api/src/list-templates/list-templates.controller.ts
Normal file
170
listify-api/src/list-templates/list-templates.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
listify-api/src/list-templates/list-templates.module.ts
Normal file
25
listify-api/src/list-templates/list-templates.module.ts
Normal 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 {}
|
||||
183
listify-api/src/list-templates/list-templates.service.spec.ts
Normal file
183
listify-api/src/list-templates/list-templates.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
450
listify-api/src/list-templates/list-templates.service.ts
Normal file
450
listify-api/src/list-templates/list-templates.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
17
listify-api/src/list-templates/template-seed.entity.ts
Normal file
17
listify-api/src/list-templates/template-seed.entity.ts
Normal 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;
|
||||
}
|
||||
7
listify-api/src/lists/dto/create-list.dto.ts
Normal file
7
listify-api/src/lists/dto/create-list.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ListTemplateKind } from '../../list-templates/list-template.types';
|
||||
|
||||
export class CreateListDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: ListTemplateKind;
|
||||
}
|
||||
14
listify-api/src/lists/dto/list-item.dto.ts
Normal file
14
listify-api/src/lists/dto/list-item.dto.ts
Normal 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;
|
||||
}
|
||||
7
listify-api/src/lists/dto/update-list.dto.ts
Normal file
7
listify-api/src/lists/dto/update-list.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ListTemplateKind } from '../../list-templates/list-template.types';
|
||||
|
||||
export class UpdateListDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: ListTemplateKind;
|
||||
}
|
||||
122
listify-api/src/lists/lists.controller.ts
Normal file
122
listify-api/src/lists/lists.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
listify-api/src/lists/lists.module.ts
Normal file
15
listify-api/src/lists/lists.module.ts
Normal 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 {}
|
||||
160
listify-api/src/lists/lists.service.spec.ts
Normal file
160
listify-api/src/lists/lists.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
371
listify-api/src/lists/lists.service.ts
Normal file
371
listify-api/src/lists/lists.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
72
listify-api/src/lists/user-list-item.entity.ts
Normal file
72
listify-api/src/lists/user-list-item.entity.ts
Normal 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;
|
||||
}
|
||||
62
listify-api/src/lists/user-list.entity.ts
Normal file
62
listify-api/src/lists/user-list.entity.ts
Normal 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[];
|
||||
}
|
||||
15
listify-api/src/mail/mail-events.listener.ts
Normal file
15
listify-api/src/mail/mail-events.listener.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
listify-api/src/mail/mail.module.ts
Normal file
9
listify-api/src/mail/mail.module.ts
Normal 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 {}
|
||||
26
listify-api/src/mail/mail.service.ts
Normal file
26
listify-api/src/mail/mail.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
6
listify-api/src/mail/mail.types.ts
Normal file
6
listify-api/src/mail/mail.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SentEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
verificationUrl: string;
|
||||
}
|
||||
10
listify-api/src/main.ts
Normal file
10
listify-api/src/main.ts
Normal 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();
|
||||
117
listify-api/src/testing/in-memory-repository.ts
Normal file
117
listify-api/src/testing/in-memory-repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
263
listify-api/test/app.e2e-spec.ts
Normal file
263
listify-api/test/app.e2e-spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
9
listify-api/test/jest-e2e.json
Normal file
9
listify-api/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
listify-api/tsconfig.build.json
Normal file
4
listify-api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
listify-api/tsconfig.json
Normal file
25
listify-api/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
listify-client/.editorconfig
Normal file
17
listify-client/.editorconfig
Normal 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
44
listify-client/.gitignore
vendored
Normal 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
|
||||
12
listify-client/.prettierrc
Normal file
12
listify-client/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
listify-client/.vscode/extensions.json
vendored
Normal file
4
listify-client/.vscode/extensions.json
vendored
Normal 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
20
listify-client/.vscode/launch.json
vendored
Normal 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
9
listify-client/.vscode/mcp.json
vendored
Normal 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
42
listify-client/.vscode/tasks.json
vendored
Normal 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
59
listify-client/README.md
Normal 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.
|
||||
82
listify-client/angular.json
Normal file
82
listify-client/angular.json
Normal 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
8725
listify-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
listify-client/package.json
Normal file
34
listify-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
listify-client/proxy.conf.json
Normal file
10
listify-client/proxy.conf.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/api": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
listify-client/public/favicon.ico
Normal file
BIN
listify-client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
22
listify-client/src/app/account/account.component.html
Normal file
22
listify-client/src/app/account/account.component.html
Normal 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>
|
||||
30
listify-client/src/app/account/account.component.scss
Normal file
30
listify-client/src/app/account/account.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
22
listify-client/src/app/account/account.component.ts
Normal file
22
listify-client/src/app/account/account.component.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
14
listify-client/src/app/app.config.ts
Normal file
14
listify-client/src/app/app.config.ts
Normal 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)
|
||||
]
|
||||
};
|
||||
130
listify-client/src/app/app.html
Normal file
130
listify-client/src/app/app.html
Normal 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>
|
||||
}
|
||||
38
listify-client/src/app/app.routes.ts
Normal file
38
listify-client/src/app/app.routes.ts
Normal 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' },
|
||||
];
|
||||
198
listify-client/src/app/app.scss
Normal file
198
listify-client/src/app/app.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
25
listify-client/src/app/app.spec.ts
Normal file
25
listify-client/src/app/app.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
47
listify-client/src/app/app.ts
Normal file
47
listify-client/src/app/app.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
listify-client/src/app/auth/auth-page.scss
Normal file
78
listify-client/src/app/auth/auth-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
10
listify-client/src/app/auth/auth.guard.ts
Normal file
10
listify-client/src/app/auth/auth.guard.ts
Normal 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');
|
||||
};
|
||||
71
listify-client/src/app/auth/auth.interceptor.ts
Normal file
71
listify-client/src/app/auth/auth.interceptor.ts
Normal 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/');
|
||||
}
|
||||
31
listify-client/src/app/auth/auth.models.ts
Normal file
31
listify-client/src/app/auth/auth.models.ts
Normal 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;
|
||||
}
|
||||
102
listify-client/src/app/auth/auth.service.ts
Normal file
102
listify-client/src/app/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
listify-client/src/app/auth/error-message.ts
Normal file
24
listify-client/src/app/auth/error-message.ts
Normal 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.';
|
||||
}
|
||||
60
listify-client/src/app/auth/login/login.component.html
Normal file
60
listify-client/src/app/auth/login/login.component.html
Normal 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>
|
||||
64
listify-client/src/app/auth/login/login.component.ts
Normal file
64
listify-client/src/app/auth/login/login.component.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
66
listify-client/src/app/auth/register/register.component.html
Normal file
66
listify-client/src/app/auth/register/register.component.html
Normal 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>
|
||||
65
listify-client/src/app/auth/register/register.component.ts
Normal file
65
listify-client/src/app/auth/register/register.component.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user