Compare commits
3 commits
ae9dd787a6
...
21cca99e9e
| Author | SHA1 | Date | |
|---|---|---|---|
| 21cca99e9e | |||
| 95cdeaedfa | |||
| 63a68f8ae0 |
236 changed files with 394 additions and 31162 deletions
9
.envrc
9
.envrc
|
|
@ -1,10 +1 @@
|
||||||
# Load the flake environment
|
|
||||||
use flake
|
use flake
|
||||||
|
|
||||||
# Use PHP and Node layouts
|
|
||||||
layout php
|
|
||||||
layout node
|
|
||||||
|
|
||||||
# Watch for dependency changes
|
|
||||||
watch_file composer.json
|
|
||||||
watch_file package.json
|
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,9 +1,3 @@
|
||||||
# Nix devshell artefacts
|
|
||||||
.postgres/
|
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|
||||||
# Process-compose state
|
|
||||||
.pc.*
|
|
||||||
process-compose-*.log
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
# Project context
|
# Project context
|
||||||
|
|
||||||
Read these on every session. Rules in them override defaults.
|
Read this on every session. Its rules override defaults.
|
||||||
|
|
||||||
@ai/shared.md
|
@ai/shared.md
|
||||||
@ai/backend-context.md
|
|
||||||
@ai/frontend-context.md
|
|
||||||
|
|
|
||||||
28
README.md
Normal file
28
README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# TIDE
|
||||||
|
|
||||||
|
TIDE is the NixOS module for the Torah Im Derech Eretz forum. It deploys the
|
||||||
|
forum with NixOS Discourse and configures its production Borg backup.
|
||||||
|
|
||||||
|
## NixOS module
|
||||||
|
|
||||||
|
Import `nixosModules.tide` or `nixosModules.default`, then provide the runtime
|
||||||
|
secret paths:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
services.tide = {
|
||||||
|
enable = true;
|
||||||
|
secretFiles = {
|
||||||
|
adminPassword = "/run/secrets/tide-admin-password";
|
||||||
|
mailPassword = "/run/secrets/tide-mail-password";
|
||||||
|
secretKeyBase = "/run/secrets/tide-secret-key-base";
|
||||||
|
borgPassphrase = "/run/secrets/borg-passphrase";
|
||||||
|
borgPrivateKey = "/run/secrets/borg-private-key";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The module owns the forum hostname, site identity, SMTP configuration, and
|
||||||
|
backup policy. The importing host owns secret provisioning.
|
||||||
|
|
||||||
|
See [RECOVERY.md](./RECOVERY.md) for the database and state restoration
|
||||||
|
procedure.
|
||||||
140
RECOVERY.md
Normal file
140
RECOVERY.md
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
# TIDE Forum State and Database Recovery
|
||||||
|
|
||||||
|
This restores the Discourse state and database from the BorgBase backup
|
||||||
|
configured by the TIDE NixOS module. It replaces the current state directory
|
||||||
|
and PostgreSQL database with the selected Borg archive.
|
||||||
|
|
||||||
|
The backup contains:
|
||||||
|
|
||||||
|
- `/var/lib/discourse`
|
||||||
|
- `/var/backup/discourse/discourse.dump`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- BorgBase repo URL:
|
||||||
|
`ssh://oas17j8p@oas17j8p.repo.borgbase.com/./repo`
|
||||||
|
- Decrypted Borg SSH private key from the flash drive
|
||||||
|
- Borg repository passphrase from the flash drive
|
||||||
|
- A target server that has already been switched to the TIDE NixOS module
|
||||||
|
- A shell with `borg`, `openssh`, `postgresql`, and `rsync`
|
||||||
|
|
||||||
|
On NixOS or another machine with Nix:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix-shell -p borgbackup openssh postgresql rsync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prepare Borg Access
|
||||||
|
|
||||||
|
Copy the Borg SSH key into a local recovery directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ~/borg-recovery/discourse
|
||||||
|
cp /path/to/flash/borg-private-key ~/borg-recovery/discourse/borg-private-key
|
||||||
|
chmod 600 ~/borg-recovery/discourse/borg-private-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the Borg connection environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export BORG_REPO='ssh://oas17j8p@oas17j8p.repo.borgbase.com/./repo'
|
||||||
|
export BORG_RSH='ssh -i ~/borg-recovery/discourse/borg-private-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new'
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the Borg passphrase without showing it on screen:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
read -rsp 'Borg passphrase: ' BORG_PASSPHRASE
|
||||||
|
export BORG_PASSPHRASE
|
||||||
|
echo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extract a Backup
|
||||||
|
|
||||||
|
List available archives:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
borg list
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose an archive name, then extract only the Discourse state and database
|
||||||
|
dump into a temporary directory. Do not extract directly into `/`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ARCHIVE='ARCHIVE_NAME_FROM_BORG_LIST'
|
||||||
|
|
||||||
|
mkdir -p ~/borg-recovery/discourse/extract
|
||||||
|
cd ~/borg-recovery/discourse/extract
|
||||||
|
|
||||||
|
borg extract ::$ARCHIVE var/lib/discourse \
|
||||||
|
var/backup/discourse/discourse.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
## Restore on the Server
|
||||||
|
|
||||||
|
Run these commands from `~/borg-recovery/discourse/extract` on the target
|
||||||
|
server. They assume the TIDE module has created the `discourse` user,
|
||||||
|
`postgresql.service`, and `discourse-postgresql.service`.
|
||||||
|
|
||||||
|
Capture absolute tool paths so they still work through `sudo`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RSYNC="$(command -v rsync)"
|
||||||
|
PSQL="$(command -v psql)"
|
||||||
|
DROPDB="$(command -v dropdb)"
|
||||||
|
PG_RESTORE="$(command -v pg_restore)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop Discourse and make sure PostgreSQL is running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl stop discourse.service
|
||||||
|
sudo systemctl start postgresql.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore `/var/lib/discourse`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo "$RSYNC" -a --delete ./var/lib/discourse/ /var/lib/discourse/
|
||||||
|
sudo chown -R discourse:discourse /var/lib/discourse
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `discourse` PostgreSQL database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u postgres "$PSQL" -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'discourse';"
|
||||||
|
|
||||||
|
sudo -u postgres "$DROPDB" --if-exists discourse
|
||||||
|
sudo systemctl restart discourse-postgresql.service
|
||||||
|
|
||||||
|
sudo -u postgres "$PG_RESTORE" \
|
||||||
|
--exit-on-error \
|
||||||
|
--no-owner \
|
||||||
|
--role=discourse \
|
||||||
|
--dbname=discourse \
|
||||||
|
./var/backup/discourse/discourse.dump
|
||||||
|
|
||||||
|
sudo systemctl restart discourse-postgresql.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Start Discourse:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl start discourse.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
Check the service and recent logs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl status discourse.service --no-pager
|
||||||
|
sudo journalctl -u discourse.service -b --no-pager -n 100
|
||||||
|
```
|
||||||
|
|
||||||
|
If networking and DNS are restored, open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://discourse.torahimderecheretz.com
|
||||||
|
```
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
# Backend context
|
|
||||||
|
|
||||||
> Read `ai/shared.md` first. This file only covers backend-specific rules.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
**Stack:** PHP 8.4, Laravel 12, PHPUnit, Composer.
|
|
||||||
**Architecture:** Domain-Driven Design. Code is organized by domain entity into
|
|
||||||
Entities, DTOs, Repositories, Use Cases, and Fakes (in-memory repos for tests).
|
|
||||||
|
|
||||||
## Code patterns
|
|
||||||
|
|
||||||
- Look at similar entities (e.g. `AgendaSlot`, `Event`) for reference
|
|
||||||
- Entities: constructor with properties, getters
|
|
||||||
- DTOs: simple data containers for creation
|
|
||||||
- Repositories: interfaces that define data access
|
|
||||||
- Use cases: business logic with Request objects
|
|
||||||
- When throwing exceptions, add `@throws` docblock
|
|
||||||
- Fakes: in-memory implementations for testing
|
|
||||||
- Look at `tests/Fakes/` for examples
|
|
||||||
- Find/lookup methods must return a new instance of the entity, not the
|
|
||||||
stored reference
|
|
||||||
- Tests: follow existing patterns in `tests/Unit/[Entity]/UseCases/`
|
|
||||||
- In `setUp`, only use fake repositories for entities under test - construct
|
|
||||||
dependency objects directly with `new` (e.g.
|
|
||||||
`new Event(id: 0, slug: 'test')`) instead of creating them through their
|
|
||||||
fake repositories
|
|
||||||
|
|
||||||
## PHP rules
|
|
||||||
|
|
||||||
- Imports: always put `use` statements at the top of the file, never use inline
|
|
||||||
imports (e.g. `\App\Foo\Bar::class`)
|
|
||||||
- Closures: never use arrow functions (`fn () =>`) - always use regular
|
|
||||||
anonymous functions (`function () { return ...; }`)
|
|
||||||
- Defaults: never use default values for function or constructor parameters -
|
|
||||||
every argument must be passed explicitly at every call site, including
|
|
||||||
nullable params (write `?Foo $bar` not `?Foo $bar = null`)
|
|
||||||
|
|
||||||
## Seeders
|
|
||||||
|
|
||||||
- Split `database/seeders/` one file per domain entity (e.g. `UserSeeder`,
|
|
||||||
`StartupUserProfileSeeder`), not one per scenario
|
|
||||||
- `DatabaseSeeder` stays as the orchestrator - calls sub-seeders via
|
|
||||||
`$this->call([...])` in dependency order
|
|
||||||
- Resolve cross-entity coupling with `findByX` lookups in later seeders
|
|
||||||
|
|
||||||
## Pre-commit
|
|
||||||
|
|
||||||
Run `composer cs:fix` on worked-on directories before committing.
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# Frontend context
|
|
||||||
|
|
||||||
> Read `ai/shared.md` first. This file only covers frontend-specific rules.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
**Stack:** Vue 3.5 (Composition API, `<script setup lang="ts">`), TypeScript
|
|
||||||
strict, Vite 8, Vue Router 5, Pinia 3, vanilla CSS (no framework). Path alias
|
|
||||||
`@` → `./src`.
|
|
||||||
|
|
||||||
**Design tokens:** Font: Inter/system-sans. Colors: text `#0a0a0a`, secondary
|
|
||||||
`#6b6b6b`, border `#e5e5e5`, accent `#0062ff`, bg `#fff`, button `#000` / hover
|
|
||||||
`#222`. Inputs/buttons: 36px height, 6px radius, 1px border → black on focus.
|
|
||||||
Spacing: 8px base unit.
|
|
||||||
|
|
||||||
## Code patterns
|
|
||||||
|
|
||||||
- Look at similar components/views for reference before writing anything
|
|
||||||
- **Views:** `src/views/PascalCasePage.vue`, register in `src/router/index.ts`
|
|
||||||
- **Components:** `src/components/PascalCase.vue`
|
|
||||||
- **Stores:** `useXxxStore` with composition style -
|
|
||||||
`defineStore('name', () => { ... return {...} })`
|
|
||||||
- **Styling:** `<style scoped>` only, vanilla CSS, reuse design tokens above
|
|
||||||
- **TypeScript:** strict mode, no `any`
|
|
||||||
- **Testing:** Cypress E2E only, mirror `cypress/e2e/login_page.cy.ts` style
|
|
||||||
|
|
||||||
## Pre-commit
|
|
||||||
|
|
||||||
Run `npm run format && npm run lint` on worked-on files before committing.
|
|
||||||
|
|
||||||
## Note on commit granularity
|
|
||||||
|
|
||||||
Frontend changes are often single-file (a new view, a new component), so
|
|
||||||
commits will frequently land as one file each. That is a consequence of the
|
|
||||||
shared "one logical change per commit" rule, not a separate per-file rule -
|
|
||||||
see `shared.md`.
|
|
||||||
74
ai/shared.md
74
ai/shared.md
|
|
@ -1,69 +1,23 @@
|
||||||
# Shared rules
|
# Shared rules
|
||||||
|
|
||||||
Rules that apply to both backend and frontend work in this repo. Stack-specific
|
## Process
|
||||||
guides (`backend-context.md`, `frontend-context.md`) extend these.
|
|
||||||
|
|
||||||
## Process (TDD)
|
0. Before editing, work on a feature branch. Never work directly on master.
|
||||||
|
1. Write and commit a failing test before changing module behavior.
|
||||||
0. Before editing any file, ensure you are on a feature branch
|
2. Implement the behavior and confirm the test passes.
|
||||||
(`git status` to confirm). If on master/main, create a branch
|
3. Run `nix fmt` and `nix flake check` before committing implementation.
|
||||||
first.
|
4. Do not push anything.
|
||||||
1. Write the test first
|
|
||||||
2. Run the test to confirm it fails
|
|
||||||
3. Commit the failing test (the "tests committed first" rule in
|
|
||||||
action - the test commit precedes the implementation commit, not
|
|
||||||
merely the implementation lines)
|
|
||||||
4. Implement the code to make the test pass
|
|
||||||
5. Run the test to confirm it passes
|
|
||||||
6. Commit the implementation
|
|
||||||
7. Repeat for each new behavior
|
|
||||||
|
|
||||||
## Code style
|
## Code style
|
||||||
|
|
||||||
- Lines should not exceed 80 columns, but should use up to 80 columns when
|
- Keep lines at or below 80 columns when practical.
|
||||||
possible - do not split lines unnecessarily
|
- Use explicit, descriptive names.
|
||||||
- Variable names: use explicit, descriptive names - never single-letter or
|
- Explore existing patterns before editing.
|
||||||
abbreviated variables (e.g. `$sponsorship` not `$s`, `$event` not `$e`)
|
- Never use em dashes in code, comments, or documentation.
|
||||||
- First, explore the codebase to understand existing patterns - look at similar
|
|
||||||
files for reference before writing anything
|
|
||||||
- Never use em dashes (—) in code, comments, or docblocks - use hyphens (-)
|
|
||||||
instead
|
|
||||||
|
|
||||||
## Git commit style
|
|
||||||
|
|
||||||
- Present tense, imperative mood (add, create, wire, fix, test)
|
|
||||||
- Lowercase
|
|
||||||
- Short (3-6 words)
|
|
||||||
- Match patterns found in git history
|
|
||||||
- Do not add any section mentioning claude as a coauthor
|
|
||||||
- Add a commit body when the subject alone cannot convey the change - e.g.
|
|
||||||
non-obvious motivation, multi-file coordination, or notable complexity
|
|
||||||
- Body: wrap at ~72 columns, separated from subject by a blank line, explain
|
|
||||||
the why and any non-obvious what
|
|
||||||
- Skip the body for trivial or self-explanatory commits
|
|
||||||
|
|
||||||
## Git commits
|
## Git commits
|
||||||
|
|
||||||
- Tests should be committed first, before implementation
|
- Use present-tense, imperative, lowercase subjects of three to six words.
|
||||||
- One logical change per commit - a commit may span multiple files when they
|
- Keep commits focused and add a wrapped body only when needed.
|
||||||
form a single logical unit (e.g. a use case with its request and exception,
|
- Do not add AI coauthor metadata.
|
||||||
or a component with its store wiring)
|
- Use descriptive kebab-case branch names.
|
||||||
- Keep commits focused: not one file per commit, not unrelated work batched
|
|
||||||
- Make commits frequent - commit each meaningful logical step as you go
|
|
||||||
- Commits are for reviewing and documenting the development of code
|
|
||||||
- When the formatter or linter modifies files outside your intended
|
|
||||||
change, either `git restore` them or land them as a separate
|
|
||||||
`format <area>` / `lint <area>` commit - never bundle drive-by
|
|
||||||
formatter churn into a feature commit
|
|
||||||
- If pre-commit lint fails on code you did not touch, do not bundle
|
|
||||||
the fix - either land the unrelated fix as its own commit first, or
|
|
||||||
note the pre-existing failure and proceed
|
|
||||||
|
|
||||||
## Branching
|
|
||||||
|
|
||||||
- Use kebab-case (e.g. `presenting-track`, `agenda-slots`, `auth-store`)
|
|
||||||
- Use descriptive feature names
|
|
||||||
- Or use type/description: `feature/presenting-track`, `fix/bug-name`
|
|
||||||
- NEVER work directly on master/main - always create and work on a branch
|
|
||||||
|
|
||||||
Do not push anything. Make commits as you go.
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
||||||
[*.{yml,yaml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[compose.yaml]
|
|
||||||
indent_size = 4
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
APP_NAME=Laravel
|
|
||||||
APP_ENV=local
|
|
||||||
APP_KEY=
|
|
||||||
APP_DEBUG=true
|
|
||||||
APP_URL=http://localhost
|
|
||||||
|
|
||||||
APP_LOCALE=en
|
|
||||||
APP_FALLBACK_LOCALE=en
|
|
||||||
APP_FAKER_LOCALE=en_US
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
|
||||||
# APP_MAINTENANCE_STORE=database
|
|
||||||
|
|
||||||
# PHP_CLI_SERVER_WORKERS=4
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
|
||||||
LOG_STACK=single
|
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
|
||||||
# DB_HOST=127.0.0.1
|
|
||||||
# DB_PORT=3306
|
|
||||||
# DB_DATABASE=laravel
|
|
||||||
# DB_USERNAME=root
|
|
||||||
# DB_PASSWORD=
|
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
|
||||||
SESSION_LIFETIME=120
|
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=database
|
|
||||||
|
|
||||||
CACHE_STORE=database
|
|
||||||
# CACHE_PREFIX=
|
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
|
||||||
MAIL_SCHEME=null
|
|
||||||
MAIL_HOST=127.0.0.1
|
|
||||||
MAIL_PORT=1025
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_FROM_ADDRESS="noreply@tide.test"
|
|
||||||
MAIL_FROM_NAME="TIDE"
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
11
backend/.gitattributes
vendored
11
backend/.gitattributes
vendored
|
|
@ -1,11 +0,0 @@
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
*.blade.php diff=html
|
|
||||||
*.css diff=css
|
|
||||||
*.html diff=html
|
|
||||||
*.md diff=markdown
|
|
||||||
*.php diff=php
|
|
||||||
|
|
||||||
/.github export-ignore
|
|
||||||
CHANGELOG.md export-ignore
|
|
||||||
.styleci.yml export-ignore
|
|
||||||
26
backend/.gitignore
vendored
26
backend/.gitignore
vendored
|
|
@ -1,26 +0,0 @@
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
.env.backup
|
|
||||||
.env.production
|
|
||||||
.phpunit.result.cache
|
|
||||||
/.fleet
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.phpunit.cache
|
|
||||||
/.vscode
|
|
||||||
/.zed
|
|
||||||
/auth.json
|
|
||||||
/node_modules
|
|
||||||
/public/build
|
|
||||||
/public/hot
|
|
||||||
/public/storage
|
|
||||||
/storage/*.key
|
|
||||||
/storage/pail
|
|
||||||
/vendor
|
|
||||||
_ide_helper.php
|
|
||||||
Homestead.json
|
|
||||||
Homestead.yaml
|
|
||||||
Thumbs.db
|
|
||||||
*~
|
|
||||||
.php-cs-fixer.cache
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
require __DIR__.'/vendor/autoload.php';
|
|
||||||
require __DIR__.'/bootstrap/app.php';
|
|
||||||
|
|
||||||
return (new Jubeki\LaravelCodeStyle\Config())
|
|
||||||
->setFinder(
|
|
||||||
PhpCsFixer\Finder::create()
|
|
||||||
->notName('*.blade.php')
|
|
||||||
->in(app_path())
|
|
||||||
->in(config_path())
|
|
||||||
->in(database_path('factories'))
|
|
||||||
->in(database_path('migrations'))
|
|
||||||
->in(database_path('seeders'))
|
|
||||||
->notPath(base_path('vendor'))
|
|
||||||
->in(base_path('routes'))
|
|
||||||
->in(base_path('tests'))
|
|
||||||
)
|
|
||||||
->setRules([
|
|
||||||
'modifier_keywords' => [
|
|
||||||
'elements' => ['method', 'property'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About Laravel
|
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
|
||||||
|
|
||||||
## Learning Laravel
|
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
|
||||||
|
|
||||||
## Laravel Sponsors
|
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
|
||||||
|
|
||||||
### Premium Partners
|
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
class BcryptPasswordHasher implements PasswordHasher
|
|
||||||
{
|
|
||||||
public function hash(string $password): string
|
|
||||||
{
|
|
||||||
return password_hash($password, PASSWORD_DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function verify(string $password, string $hash): bool
|
|
||||||
{
|
|
||||||
return password_verify($password, $hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
interface Clock
|
|
||||||
{
|
|
||||||
public function now(): DateTimeImmutable;
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
class CreateSessionDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $token,
|
|
||||||
public User $user,
|
|
||||||
public DateTimeImmutable $createdAt,
|
|
||||||
public DateTimeImmutable $expiresAt,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
|
|
||||||
class EloquentSessionRepository implements SessionRepository
|
|
||||||
{
|
|
||||||
public function __construct(private UserRepository $userRepo) {}
|
|
||||||
|
|
||||||
public function create(CreateSessionDto $dto): Session
|
|
||||||
{
|
|
||||||
SessionModel::create([
|
|
||||||
'token' => $dto->token,
|
|
||||||
'user_id' => $dto->user->getId(),
|
|
||||||
'created_at' => $dto->createdAt,
|
|
||||||
'expires_at' => $dto->expiresAt,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new Session(
|
|
||||||
token: $dto->token,
|
|
||||||
user: $dto->user,
|
|
||||||
createdAt: $dto->createdAt,
|
|
||||||
expiresAt: $dto->expiresAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?Session
|
|
||||||
{
|
|
||||||
$model = SessionModel::find($token);
|
|
||||||
if ($model === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$user = $this->userRepo->find($model->user_id);
|
|
||||||
if ($user === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$utc = new DateTimeZone('UTC');
|
|
||||||
|
|
||||||
return new Session(
|
|
||||||
token: $model->token,
|
|
||||||
user: $user,
|
|
||||||
createdAt: new DateTimeImmutable(
|
|
||||||
$model->created_at->toDateTimeString(),
|
|
||||||
$utc
|
|
||||||
),
|
|
||||||
expiresAt: new DateTimeImmutable(
|
|
||||||
$model->expires_at->toDateTimeString(),
|
|
||||||
$utc
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteByToken(string $token): void
|
|
||||||
{
|
|
||||||
SessionModel::where('token', $token)->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
interface PasswordHasher
|
|
||||||
{
|
|
||||||
public function hash(string $password): string;
|
|
||||||
|
|
||||||
public function verify(string $password, string $hash): bool;
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
class RandomTokenGenerator implements TokenGenerator
|
|
||||||
{
|
|
||||||
public function generate(): string
|
|
||||||
{
|
|
||||||
return bin2hex(random_bytes(32));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
class Session
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private string $token,
|
|
||||||
private User $user,
|
|
||||||
private DateTimeImmutable $createdAt,
|
|
||||||
private DateTimeImmutable $expiresAt,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getToken(): string
|
|
||||||
{
|
|
||||||
return $this->token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUser(): User
|
|
||||||
{
|
|
||||||
return $this->user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getExpiresAt(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isExpired(DateTimeImmutable $now): bool
|
|
||||||
{
|
|
||||||
return $now >= $this->expiresAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property string $token
|
|
||||||
* @property int $user_id
|
|
||||||
* @property Carbon $created_at
|
|
||||||
* @property Carbon $expires_at
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|SessionModel newModelQuery()
|
|
||||||
* @method static Builder<static>|SessionModel newQuery()
|
|
||||||
* @method static Builder<static>|SessionModel query()
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class SessionModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'sessions';
|
|
||||||
|
|
||||||
protected $primaryKey = 'token';
|
|
||||||
|
|
||||||
public $incrementing = false;
|
|
||||||
|
|
||||||
protected $keyType = 'string';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'token',
|
|
||||||
'user_id',
|
|
||||||
'created_at',
|
|
||||||
'expires_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'created_at' => 'datetime',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
interface SessionRepository
|
|
||||||
{
|
|
||||||
public function create(CreateSessionDto $dto): Session;
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?Session;
|
|
||||||
|
|
||||||
public function deleteByToken(string $token): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
|
|
||||||
class SystemClock implements Clock
|
|
||||||
{
|
|
||||||
public function now(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
interface TokenGenerator
|
|
||||||
{
|
|
||||||
public function generate(): string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth\UseCases\AuthenticateUser;
|
|
||||||
|
|
||||||
use App\Auth\PasswordHasher;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\UnauthorizedException;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class AuthenticateUser
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
private PasswordHasher $hasher,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws UnauthorizedException
|
|
||||||
*/
|
|
||||||
public function execute(AuthenticateUserRequest $request): User
|
|
||||||
{
|
|
||||||
if ($request->email === null || $request->email === '') {
|
|
||||||
throw new BadRequestException('email is required');
|
|
||||||
}
|
|
||||||
if ($request->password === null || $request->password === '') {
|
|
||||||
throw new BadRequestException('password is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$email = new EmailAddress($request->email);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
throw new BadRequestException($exception->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->userRepo->findByEmail($email);
|
|
||||||
if ($user === null) {
|
|
||||||
throw new UnauthorizedException('invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
$passwordMatches = $this->hasher->verify(
|
|
||||||
$request->password,
|
|
||||||
$user->getPasswordHash(),
|
|
||||||
);
|
|
||||||
if (! $passwordMatches) {
|
|
||||||
throw new UnauthorizedException('invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth\UseCases\AuthenticateUser;
|
|
||||||
|
|
||||||
class AuthenticateUserRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?string $email,
|
|
||||||
public ?string $password,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth\UseCases\CreateSession;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Auth\CreateSessionDto;
|
|
||||||
use App\Auth\Session;
|
|
||||||
use App\Auth\SessionRepository;
|
|
||||||
use App\Auth\TokenGenerator;
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
class CreateSession
|
|
||||||
{
|
|
||||||
private const SESSION_LIFETIME = '+7 days';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private SessionRepository $sessionRepo,
|
|
||||||
private TokenGenerator $tokenGenerator,
|
|
||||||
private Clock $clock,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function execute(User $user): Session
|
|
||||||
{
|
|
||||||
$now = $this->clock->now();
|
|
||||||
$expiresAt = $now->modify(self::SESSION_LIFETIME);
|
|
||||||
|
|
||||||
return $this->sessionRepo->create(new CreateSessionDto(
|
|
||||||
token: $this->tokenGenerator->generate(),
|
|
||||||
user: $user,
|
|
||||||
createdAt: $now,
|
|
||||||
expiresAt: $expiresAt,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth\UseCases\Logout;
|
|
||||||
|
|
||||||
use App\Auth\SessionRepository;
|
|
||||||
|
|
||||||
class Logout
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private SessionRepository $sessionRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function execute(string $token): void
|
|
||||||
{
|
|
||||||
$this->sessionRepo->deleteByToken($token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
class Comment
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private int $postId,
|
|
||||||
private int $userId,
|
|
||||||
private string $body,
|
|
||||||
private DateTimeImmutable $createdAt,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPostId(): int
|
|
||||||
{
|
|
||||||
return $this->postId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): int
|
|
||||||
{
|
|
||||||
return $this->userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBody(): string
|
|
||||||
{
|
|
||||||
return $this->body;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $post_id
|
|
||||||
* @property int $user_id
|
|
||||||
* @property string $body
|
|
||||||
* @property DateTimeImmutable $created_at
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|CommentModel newModelQuery()
|
|
||||||
* @method static Builder<static>|CommentModel newQuery()
|
|
||||||
* @method static Builder<static>|CommentModel query()
|
|
||||||
* @method static Builder<static>|CommentModel whereId($value)
|
|
||||||
* @method static Builder<static>|CommentModel wherePostId($value)
|
|
||||||
* @method static Builder<static>|CommentModel whereUserId($value)
|
|
||||||
* @method static Builder<static>|CommentModel whereBody($value)
|
|
||||||
* @method static Builder<static>|CommentModel whereCreatedAt($value)
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class CommentModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'comments';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'post_id',
|
|
||||||
'user_id',
|
|
||||||
'body',
|
|
||||||
'created_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'created_at' => 'immutable_datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment;
|
|
||||||
|
|
||||||
interface CommentRepository
|
|
||||||
{
|
|
||||||
public function create(CreateCommentDto $dto): Comment;
|
|
||||||
|
|
||||||
public function find(int $id): ?Comment;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Comment[]
|
|
||||||
*/
|
|
||||||
public function findByPostId(int $postId): array;
|
|
||||||
|
|
||||||
public function delete(int $id): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
readonly class CreateCommentDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $postId,
|
|
||||||
public int $userId,
|
|
||||||
public string $body,
|
|
||||||
public DateTimeImmutable $createdAt,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
|
|
||||||
class EloquentCommentRepository implements CommentRepository
|
|
||||||
{
|
|
||||||
public function create(CreateCommentDto $dto): Comment
|
|
||||||
{
|
|
||||||
$model = CommentModel::create([
|
|
||||||
'post_id' => $dto->postId,
|
|
||||||
'user_id' => $dto->userId,
|
|
||||||
'body' => $dto->body,
|
|
||||||
'created_at' => $dto->createdAt,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): ?Comment
|
|
||||||
{
|
|
||||||
$model = CommentModel::find($id);
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Comment[]
|
|
||||||
*/
|
|
||||||
public function findByPostId(int $postId): array
|
|
||||||
{
|
|
||||||
$models = CommentModel::query()
|
|
||||||
->where('post_id', $postId)
|
|
||||||
->orderBy('created_at', 'asc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $models->map(
|
|
||||||
function (CommentModel $model) {
|
|
||||||
return $this->toDomain($model);
|
|
||||||
},
|
|
||||||
)->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $id): void
|
|
||||||
{
|
|
||||||
CommentModel::query()->where('id', $id)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(CommentModel $model): Comment
|
|
||||||
{
|
|
||||||
$utc = new DateTimeZone('UTC');
|
|
||||||
|
|
||||||
return new Comment(
|
|
||||||
id: $model->id,
|
|
||||||
postId: $model->post_id,
|
|
||||||
userId: $model->user_id,
|
|
||||||
body: $model->body,
|
|
||||||
createdAt: new DateTimeImmutable(
|
|
||||||
$model->created_at->toDateTimeString(),
|
|
||||||
$utc,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment\UseCases\CreateComment;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Comment\Comment;
|
|
||||||
use App\Comment\CommentRepository;
|
|
||||||
use App\Comment\CreateCommentDto;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class CreateComment
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private CommentRepository $commentRepo,
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
private Clock $clock,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function execute(CreateCommentRequest $request): Comment
|
|
||||||
{
|
|
||||||
if ($request->postId <= 0) {
|
|
||||||
throw new BadRequestException('postId must be positive');
|
|
||||||
}
|
|
||||||
if ($request->userId <= 0) {
|
|
||||||
throw new BadRequestException('userId must be positive');
|
|
||||||
}
|
|
||||||
$body = $request->body === null ? '' : trim($request->body);
|
|
||||||
if ($body === '') {
|
|
||||||
throw new BadRequestException('body is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->postRepo->find($request->postId) === null) {
|
|
||||||
throw new DomainException('post not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->commentRepo->create(new CreateCommentDto(
|
|
||||||
postId: $request->postId,
|
|
||||||
userId: $request->userId,
|
|
||||||
body: $body,
|
|
||||||
createdAt: $this->clock->now(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment\UseCases\CreateComment;
|
|
||||||
|
|
||||||
class CreateCommentRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $postId,
|
|
||||||
public int $userId,
|
|
||||||
public ?string $body,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment\UseCases\DeleteComment;
|
|
||||||
|
|
||||||
use App\Comment\CommentRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
|
|
||||||
class DeleteComment
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private CommentRepository $commentRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws ForbiddenException
|
|
||||||
*/
|
|
||||||
public function execute(DeleteCommentRequest $request): void
|
|
||||||
{
|
|
||||||
if ($request->commentId <= 0) {
|
|
||||||
throw new BadRequestException('commentId must be positive');
|
|
||||||
}
|
|
||||||
if ($request->requesterId <= 0) {
|
|
||||||
throw new BadRequestException('requesterId must be positive');
|
|
||||||
}
|
|
||||||
|
|
||||||
$comment = $this->commentRepo->find($request->commentId);
|
|
||||||
if ($comment === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isAuthor = $comment->getUserId() === $request->requesterId;
|
|
||||||
if (! $isAuthor && ! $request->requesterIsAdmin) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'requester is not allowed to delete this comment'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->commentRepo->delete($request->commentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment\UseCases\DeleteComment;
|
|
||||||
|
|
||||||
class DeleteCommentRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $commentId,
|
|
||||||
public int $requesterId,
|
|
||||||
public bool $requesterIsAdmin,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment\UseCases\ListCommentsForPost;
|
|
||||||
|
|
||||||
use App\Comment\Comment;
|
|
||||||
use App\Comment\CommentRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
|
|
||||||
class ListCommentsForPost
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private CommentRepository $commentRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Comment[]
|
|
||||||
*
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
public function execute(ListCommentsForPostRequest $request): array
|
|
||||||
{
|
|
||||||
if ($request->postId <= 0) {
|
|
||||||
throw new BadRequestException('postId must be positive');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->commentRepo->findByPostId($request->postId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Comment\UseCases\ListCommentsForPost;
|
|
||||||
|
|
||||||
class ListCommentsForPostRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $postId,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class UserPromoteCommand extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'user:promote {email}';
|
|
||||||
|
|
||||||
protected $description = 'Mark the user with the given email as an admin';
|
|
||||||
|
|
||||||
public function handle(UserRepository $userRepo): int
|
|
||||||
{
|
|
||||||
$rawEmail = $this->argument('email');
|
|
||||||
if (! is_string($rawEmail) || $rawEmail === '') {
|
|
||||||
$this->error('email is required');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$email = new EmailAddress($rawEmail);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
$this->error($exception->getMessage());
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $userRepo->findByEmail($email);
|
|
||||||
if ($user === null) {
|
|
||||||
$this->error("user not found: {$rawEmail}");
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user->isAdmin()) {
|
|
||||||
$this->info("{$rawEmail} is already an admin");
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$userRepo->update(new User(
|
|
||||||
id: $user->getId(),
|
|
||||||
email: $user->getEmail(),
|
|
||||||
displayName: $user->getDisplayName(),
|
|
||||||
passwordHash: $user->getPasswordHash(),
|
|
||||||
isAdmin: true,
|
|
||||||
emailConfirmedAt: $user->getEmailConfirmedAt(),
|
|
||||||
));
|
|
||||||
$this->info("{$rawEmail} is now an admin");
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
|
||||||
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
|
|
||||||
use App\Auth\UseCases\CreateSession\CreateSession;
|
|
||||||
use App\Auth\UseCases\Logout\Logout;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\UnauthorizedException;
|
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
|
||||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail;
|
|
||||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmailRequest;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUser;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUserRequest;
|
|
||||||
use App\User\User;
|
|
||||||
use DomainException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
|
||||||
|
|
||||||
class AuthController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private SignupUser $signupUser,
|
|
||||||
private ConfirmUserEmail $confirmUserEmail,
|
|
||||||
private AuthenticateUser $authenticateUser,
|
|
||||||
private CreateSession $createSession,
|
|
||||||
private Logout $logoutUseCase,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function signup(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->signupUser->execute(new SignupUserRequest(
|
|
||||||
email: $request->input('email'),
|
|
||||||
displayName: $request->input('displayName'),
|
|
||||||
));
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(null, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmEmail(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->confirmUserEmail->execute(new ConfirmUserEmailRequest(
|
|
||||||
token: $request->input('token'),
|
|
||||||
password: $request->input('password'),
|
|
||||||
));
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(null, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$user = $this->authenticateUser->execute(
|
|
||||||
new AuthenticateUserRequest(
|
|
||||||
email: $request->input('email'),
|
|
||||||
password: $request->input('password'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (UnauthorizedException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$session = $this->createSession->execute($user);
|
|
||||||
|
|
||||||
$response = new JsonResponse([
|
|
||||||
'user' => $this->buildUserPayload($user),
|
|
||||||
], 200);
|
|
||||||
|
|
||||||
return $response->withCookie(Cookie::create(
|
|
||||||
name: AuthMiddleware::COOKIE_NAME,
|
|
||||||
value: $session->getToken(),
|
|
||||||
expire: $session->getExpiresAt()->getTimestamp(),
|
|
||||||
path: '/',
|
|
||||||
domain: null,
|
|
||||||
secure: false,
|
|
||||||
httpOnly: true,
|
|
||||||
raw: false,
|
|
||||||
sameSite: Cookie::SAMESITE_LAX,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function me(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'user' => $this->buildUserPayload($user),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function logout(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$token = $request->cookie(AuthMiddleware::COOKIE_NAME);
|
|
||||||
if (is_string($token) && $token !== '') {
|
|
||||||
$this->logoutUseCase->execute($token);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = new JsonResponse(null, 204);
|
|
||||||
|
|
||||||
return $response->withCookie(Cookie::create(
|
|
||||||
name: AuthMiddleware::COOKIE_NAME,
|
|
||||||
value: '',
|
|
||||||
expire: 1,
|
|
||||||
path: '/',
|
|
||||||
domain: null,
|
|
||||||
secure: false,
|
|
||||||
httpOnly: true,
|
|
||||||
raw: false,
|
|
||||||
sameSite: Cookie::SAMESITE_LAX,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* id: int,
|
|
||||||
* email: string,
|
|
||||||
* displayName: string,
|
|
||||||
* isAdmin: bool
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function buildUserPayload(User $user): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $user->getId(),
|
|
||||||
'email' => $user->getEmail()->value(),
|
|
||||||
'displayName' => $user->getDisplayName(),
|
|
||||||
'isAdmin' => $user->isAdmin(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use App\Comment\Comment;
|
|
||||||
use App\Comment\UseCases\CreateComment\CreateComment;
|
|
||||||
use App\Comment\UseCases\CreateComment\CreateCommentRequest;
|
|
||||||
use App\Comment\UseCases\DeleteComment\DeleteComment;
|
|
||||||
use App\Comment\UseCases\DeleteComment\DeleteCommentRequest;
|
|
||||||
use App\Comment\UseCases\ListCommentsForPost\ListCommentsForPost;
|
|
||||||
use App\Comment\UseCases\ListCommentsForPost\ListCommentsForPostRequest;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class CommentController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private CreateComment $createComment,
|
|
||||||
private DeleteComment $deleteComment,
|
|
||||||
private ListCommentsForPost $listCommentsForPost,
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function listForPost(Request $request, int $postId): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$comments = $this->listCommentsForPost->execute(
|
|
||||||
new ListCommentsForPostRequest(postId: $postId),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'comments' => array_map(
|
|
||||||
function (Comment $comment) {
|
|
||||||
return $this->serialize($comment);
|
|
||||||
},
|
|
||||||
$comments,
|
|
||||||
),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(Request $request, int $postId): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
try {
|
|
||||||
$comment = $this->createComment->execute(new CreateCommentRequest(
|
|
||||||
postId: $postId,
|
|
||||||
userId: $user->getId(),
|
|
||||||
body: $request->input('body'),
|
|
||||||
));
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'comment' => $this->serialize($comment),
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(Request $request, int $id): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
try {
|
|
||||||
$this->deleteComment->execute(new DeleteCommentRequest(
|
|
||||||
commentId: $id,
|
|
||||||
requesterId: $user->getId(),
|
|
||||||
requesterIsAdmin: $user->isAdmin(),
|
|
||||||
));
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (ForbiddenException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(null, 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* id: int,
|
|
||||||
* postId: int,
|
|
||||||
* userId: int,
|
|
||||||
* authorDisplayName: string,
|
|
||||||
* body: string,
|
|
||||||
* createdAt: string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function serialize(Comment $comment): array
|
|
||||||
{
|
|
||||||
$author = $this->userRepo->find($comment->getUserId());
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $comment->getId(),
|
|
||||||
'postId' => $comment->getPostId(),
|
|
||||||
'userId' => $comment->getUserId(),
|
|
||||||
'authorDisplayName' => $author === null
|
|
||||||
? ''
|
|
||||||
: $author->getDisplayName(),
|
|
||||||
'body' => $comment->getBody(),
|
|
||||||
'createdAt' => $comment->getCreatedAt()->format(DATE_ATOM),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\UseCases\ClearFeaturedPost\ClearFeaturedPost;
|
|
||||||
use App\Post\UseCases\ClearFeaturedPost\ClearFeaturedPostRequest;
|
|
||||||
use App\Post\UseCases\CreatePost\CreatePost;
|
|
||||||
use App\Post\UseCases\CreatePost\CreatePostRequest;
|
|
||||||
use App\Post\UseCases\DeletePost\DeletePost;
|
|
||||||
use App\Post\UseCases\DeletePost\DeletePostRequest;
|
|
||||||
use App\Post\UseCases\GetPost\GetPost;
|
|
||||||
use App\Post\UseCases\ListFeaturedPosts\ListFeaturedPosts;
|
|
||||||
use App\Post\UseCases\ListRecentPosts\ListRecentPosts;
|
|
||||||
use App\Post\UseCases\ListRecentPosts\ListRecentPostsRequest;
|
|
||||||
use App\Post\UseCases\ListUserPosts\ListUserPosts;
|
|
||||||
use App\Post\UseCases\ListUserPosts\ListUserPostsRequest;
|
|
||||||
use App\Post\UseCases\SetFeaturedPost\SetFeaturedPost;
|
|
||||||
use App\Post\UseCases\SetFeaturedPost\SetFeaturedPostRequest;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class PostController
|
|
||||||
{
|
|
||||||
private const RECENT_LIMIT = 20;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private CreatePost $createPost,
|
|
||||||
private DeletePost $deletePost,
|
|
||||||
private GetPost $getPost,
|
|
||||||
private ListRecentPosts $listRecentPosts,
|
|
||||||
private ListUserPosts $listUserPosts,
|
|
||||||
private SetFeaturedPost $setFeaturedPost,
|
|
||||||
private ClearFeaturedPost $clearFeaturedPost,
|
|
||||||
private ListFeaturedPosts $listFeaturedPosts,
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function recent(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$posts = $this->listRecentPosts->execute(
|
|
||||||
new ListRecentPostsRequest(limit: self::RECENT_LIMIT),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'posts' => array_map(
|
|
||||||
function (Post $post) {
|
|
||||||
return $this->serialize($post);
|
|
||||||
},
|
|
||||||
$posts,
|
|
||||||
),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, int $id): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$post = $this->getPost->execute($id);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ($post === null) {
|
|
||||||
return new JsonResponse(['error' => 'post not found'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'post' => $this->serialize($post),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listByUser(
|
|
||||||
Request $request,
|
|
||||||
string $displayName,
|
|
||||||
): JsonResponse {
|
|
||||||
$user = $this->userRepo->findByDisplayName($displayName);
|
|
||||||
if ($user === null) {
|
|
||||||
return new JsonResponse(['error' => 'user not found'], 404);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$posts = $this->listUserPosts->execute(
|
|
||||||
new ListUserPostsRequest(userId: $user->getId()),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'user' => [
|
|
||||||
'id' => $user->getId(),
|
|
||||||
'displayName' => $user->getDisplayName(),
|
|
||||||
],
|
|
||||||
'posts' => array_map(
|
|
||||||
function (Post $post) {
|
|
||||||
return $this->serialize($post);
|
|
||||||
},
|
|
||||||
$posts,
|
|
||||||
),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
try {
|
|
||||||
$post = $this->createPost->execute(new CreatePostRequest(
|
|
||||||
userId: $user->getId(),
|
|
||||||
title: $request->input('title'),
|
|
||||||
body: $request->input('body'),
|
|
||||||
));
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'post' => $this->serialize($post),
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listFeatured(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$posts = $this->listFeaturedPosts->execute();
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'posts' => array_map(
|
|
||||||
function (Post $post) {
|
|
||||||
return $this->serialize($post);
|
|
||||||
},
|
|
||||||
$posts,
|
|
||||||
),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function feature(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
try {
|
|
||||||
$post = $this->setFeaturedPost->execute(
|
|
||||||
new SetFeaturedPostRequest(
|
|
||||||
postId: (int) $request->input('postId'),
|
|
||||||
slot: (int) $request->input('slot'),
|
|
||||||
requesterIsAdmin: $user->isAdmin(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (ForbiddenException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 403,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'post' => $this->serialize($post),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function unfeature(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
try {
|
|
||||||
$this->clearFeaturedPost->execute(
|
|
||||||
new ClearFeaturedPostRequest(
|
|
||||||
postId: (int) $request->input('postId'),
|
|
||||||
requesterIsAdmin: $user->isAdmin(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (ForbiddenException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(null, 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(Request $request, int $id): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $user */
|
|
||||||
$user = $request->attributes->get('user');
|
|
||||||
try {
|
|
||||||
$this->deletePost->execute(new DeletePostRequest(
|
|
||||||
postId: $id,
|
|
||||||
requesterId: $user->getId(),
|
|
||||||
requesterIsAdmin: $user->isAdmin(),
|
|
||||||
));
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (ForbiddenException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 403,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(null, 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* id: int,
|
|
||||||
* userId: int,
|
|
||||||
* authorDisplayName: string,
|
|
||||||
* title: string,
|
|
||||||
* body: string,
|
|
||||||
* createdAt: string,
|
|
||||||
* featureSlot: ?int
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function serialize(Post $post): array
|
|
||||||
{
|
|
||||||
$author = $this->userRepo->find($post->getUserId());
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $post->getId(),
|
|
||||||
'userId' => $post->getUserId(),
|
|
||||||
'authorDisplayName' => $author === null
|
|
||||||
? ''
|
|
||||||
: $author->getDisplayName(),
|
|
||||||
'title' => $post->getTitle(),
|
|
||||||
'body' => $post->getBody(),
|
|
||||||
'createdAt' => $post->getCreatedAt()->format(DATE_ATOM),
|
|
||||||
'featureSlot' => $post->getFeatureSlot(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
use App\User\UseCases\PromoteUserToAdmin\PromoteUserToAdmin;
|
|
||||||
use App\User\UseCases\PromoteUserToAdmin\PromoteUserToAdminRequest;
|
|
||||||
use App\User\UseCases\SearchUsers\SearchUsers;
|
|
||||||
use App\User\UseCases\SearchUsers\SearchUsersRequest;
|
|
||||||
use App\User\User;
|
|
||||||
use DomainException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class UserController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private SearchUsers $searchUsers,
|
|
||||||
private PromoteUserToAdmin $promoteUserToAdmin,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function search(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$query = $request->query('q');
|
|
||||||
if (! is_string($query) || trim($query) === '') {
|
|
||||||
return new JsonResponse(['users' => []], 200);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$results = $this->searchUsers->execute(
|
|
||||||
new SearchUsersRequest(query: $query),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'users' => array_map(
|
|
||||||
function (User $user) {
|
|
||||||
return [
|
|
||||||
'id' => $user->getId(),
|
|
||||||
'email' => $user->getEmail()->value(),
|
|
||||||
'displayName' => $user->getDisplayName(),
|
|
||||||
'isAdmin' => $user->isAdmin(),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
$results,
|
|
||||||
),
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function promote(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
/** @var User $requester */
|
|
||||||
$requester = $request->attributes->get('user');
|
|
||||||
try {
|
|
||||||
$promoted = $this->promoteUserToAdmin->execute(
|
|
||||||
new PromoteUserToAdminRequest(
|
|
||||||
targetUserId: (int) $request->input('userId'),
|
|
||||||
requesterIsAdmin: $requester->isAdmin(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 400,
|
|
||||||
);
|
|
||||||
} catch (ForbiddenException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 403,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()], 404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'user' => [
|
|
||||||
'id' => $promoted->getId(),
|
|
||||||
'email' => $promoted->getEmail()->value(),
|
|
||||||
'displayName' => $promoted->getDisplayName(),
|
|
||||||
'isAdmin' => $promoted->isAdmin(),
|
|
||||||
],
|
|
||||||
], 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
readonly class CreateEmailConfirmationTokenDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public User $user,
|
|
||||||
public DateTimeImmutable $availableTo,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class EloquentEmailConfirmationTokenRepository implements EmailConfirmationTokenRepository
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function create(
|
|
||||||
CreateEmailConfirmationTokenDto $dto,
|
|
||||||
): EmailConfirmationToken {
|
|
||||||
$model = EmailConfirmationTokenModel::create([
|
|
||||||
'user_id' => $dto->user->getId(),
|
|
||||||
'token' => bin2hex(random_bytes(32)),
|
|
||||||
'available_to' => $dto->availableTo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?EmailConfirmationToken
|
|
||||||
{
|
|
||||||
$model = EmailConfirmationTokenModel::where(
|
|
||||||
'token', $token,
|
|
||||||
)->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByUser(User $user): ?EmailConfirmationToken
|
|
||||||
{
|
|
||||||
$model = EmailConfirmationTokenModel::where(
|
|
||||||
'user_id', $user->getId(),
|
|
||||||
)->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $id): void
|
|
||||||
{
|
|
||||||
EmailConfirmationTokenModel::query()->where('id', $id)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(
|
|
||||||
EmailConfirmationTokenModel $model,
|
|
||||||
): EmailConfirmationToken {
|
|
||||||
$user = $this->userRepo->find($model->user_id);
|
|
||||||
if ($user === null) {
|
|
||||||
throw new DomainException(
|
|
||||||
"User with id {$model->user_id} not found"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$availableTo = new DateTimeImmutable(
|
|
||||||
$model->available_to->toDateTimeString(),
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new EmailConfirmationToken(
|
|
||||||
id: $model->id,
|
|
||||||
user: $user,
|
|
||||||
availableTo: $availableTo,
|
|
||||||
token: $model->token,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
class EmailConfirmationToken
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private User $user,
|
|
||||||
private DateTimeImmutable $availableTo,
|
|
||||||
private string $token,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUser(): User
|
|
||||||
{
|
|
||||||
return $this->user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAvailableTo(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->availableTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getToken(): string
|
|
||||||
{
|
|
||||||
return $this->token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $user_id
|
|
||||||
* @property string $token
|
|
||||||
* @property DateTimeImmutable $available_to
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel newModelQuery()
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel newQuery()
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel query()
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel whereId($value)
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel whereUserId($value)
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel whereToken($value)
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel whereAvailableTo($value)
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class EmailConfirmationTokenModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'email_confirmation_tokens';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'user_id',
|
|
||||||
'token',
|
|
||||||
'available_to',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'available_to' => 'immutable_datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
interface EmailConfirmationTokenRepository
|
|
||||||
{
|
|
||||||
public function create(
|
|
||||||
CreateEmailConfirmationTokenDto $dto,
|
|
||||||
): EmailConfirmationToken;
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?EmailConfirmationToken;
|
|
||||||
|
|
||||||
public function findByUser(User $user): ?EmailConfirmationToken;
|
|
||||||
|
|
||||||
public function delete(int $id): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
interface EmailFactory
|
|
||||||
{
|
|
||||||
public function makeConfirmationEmail(string $token): string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
interface Emailer
|
|
||||||
{
|
|
||||||
public function send(string $from, string $to, string $body): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
class LaravelEmailFactory implements EmailFactory
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private string $confirmationUrlPrefix,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function makeConfirmationEmail(string $token): string
|
|
||||||
{
|
|
||||||
return "Confirm your email: {$this->confirmationUrlPrefix}{$token}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Mail\Mailer;
|
|
||||||
use Illuminate\Mail\Message;
|
|
||||||
|
|
||||||
class LaravelMailer implements Emailer
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Mailer $mailer,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function send(string $from, string $to, string $body): void
|
|
||||||
{
|
|
||||||
$this->mailer->raw(
|
|
||||||
$body,
|
|
||||||
function (Message $message) use ($from, $to) {
|
|
||||||
$message->from($from)
|
|
||||||
->to($to)
|
|
||||||
->subject('TIDE');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
use Mailjet\Client;
|
|
||||||
use Mailjet\Resources;
|
|
||||||
|
|
||||||
class MailjetMailer implements Emailer
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Client $mailjet,
|
|
||||||
private string $fromName,
|
|
||||||
) {}
|
|
||||||
public function send(string $from, string $to, string $body): void
|
|
||||||
{
|
|
||||||
$this->mailjet->post(Resources::$Email, [
|
|
||||||
'body' => [
|
|
||||||
'Messages' => [
|
|
||||||
[
|
|
||||||
'From' => [
|
|
||||||
'Email' => $from,
|
|
||||||
'Name' => $this->fromName,
|
|
||||||
],
|
|
||||||
'To' => [
|
|
||||||
[
|
|
||||||
'Email' => $to,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'Subject' => 'TIDE',
|
|
||||||
'TextPart' => $body,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class BadRequestException extends DomainException {}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class ForbiddenException extends DomainException {}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class UnauthorizedException extends DomainException {}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Auth\SessionRepository;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class AuthMiddleware
|
|
||||||
{
|
|
||||||
public const COOKIE_NAME = 'auth_token';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private SessionRepository $sessionRepo,
|
|
||||||
private Clock $clock,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Closure(Request): Response $next
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$token = $request->cookie(self::COOKIE_NAME);
|
|
||||||
if (! is_string($token) || $token === '') {
|
|
||||||
return $this->unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
$session = $this->sessionRepo->findByToken($token);
|
|
||||||
if ($session === null) {
|
|
||||||
return $this->unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($session->isExpired($this->clock->now())) {
|
|
||||||
$this->sessionRepo->deleteByToken($token);
|
|
||||||
|
|
||||||
return $this->unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->attributes->set('user', $session->getUser());
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function unauthorized(): JsonResponse
|
|
||||||
{
|
|
||||||
return new JsonResponse(['error' => 'unauthenticated'], 401);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
readonly class CreatePostDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $userId,
|
|
||||||
public string $title,
|
|
||||||
public string $body,
|
|
||||||
public DateTimeImmutable $createdAt,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class EloquentPostRepository implements PostRepository
|
|
||||||
{
|
|
||||||
public function create(CreatePostDto $dto): Post
|
|
||||||
{
|
|
||||||
$model = PostModel::create([
|
|
||||||
'user_id' => $dto->userId,
|
|
||||||
'title' => $dto->title,
|
|
||||||
'body' => $dto->body,
|
|
||||||
'created_at' => $dto->createdAt,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): ?Post
|
|
||||||
{
|
|
||||||
$model = PostModel::find($id);
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public function findByUserId(int $userId): array
|
|
||||||
{
|
|
||||||
$models = PostModel::query()
|
|
||||||
->where('user_id', $userId)
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $models->map(
|
|
||||||
function (PostModel $model) {
|
|
||||||
return $this->toDomain($model);
|
|
||||||
},
|
|
||||||
)->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public function findRecent(int $limit): array
|
|
||||||
{
|
|
||||||
$models = PostModel::query()
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->limit($limit)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $models->map(
|
|
||||||
function (PostModel $model) {
|
|
||||||
return $this->toDomain($model);
|
|
||||||
},
|
|
||||||
)->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $id): void
|
|
||||||
{
|
|
||||||
PostModel::query()->where('id', $id)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws RuntimeException
|
|
||||||
*/
|
|
||||||
public function update(Post $post): Post
|
|
||||||
{
|
|
||||||
$model = PostModel::find($post->getId());
|
|
||||||
if ($model === null) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"Post with id: {$post->getId()} does not exist"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$model->user_id = $post->getUserId();
|
|
||||||
$model->title = $post->getTitle();
|
|
||||||
$model->body = $post->getBody();
|
|
||||||
$model->created_at = $post->getCreatedAt();
|
|
||||||
$model->feature_slot = $post->getFeatureSlot();
|
|
||||||
$model->save();
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByFeatureSlot(int $slot): ?Post
|
|
||||||
{
|
|
||||||
$model = PostModel::query()
|
|
||||||
->where('feature_slot', $slot)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public function findFeatured(): array
|
|
||||||
{
|
|
||||||
$models = PostModel::query()
|
|
||||||
->whereNotNull('feature_slot')
|
|
||||||
->orderBy('feature_slot', 'asc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $models->map(
|
|
||||||
function (PostModel $model) {
|
|
||||||
return $this->toDomain($model);
|
|
||||||
},
|
|
||||||
)->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(PostModel $model): Post
|
|
||||||
{
|
|
||||||
$utc = new DateTimeZone('UTC');
|
|
||||||
|
|
||||||
return new Post(
|
|
||||||
id: $model->id,
|
|
||||||
userId: $model->user_id,
|
|
||||||
title: $model->title,
|
|
||||||
body: $model->body,
|
|
||||||
createdAt: new DateTimeImmutable(
|
|
||||||
$model->created_at->toDateTimeString(),
|
|
||||||
$utc,
|
|
||||||
),
|
|
||||||
featureSlot: $model->feature_slot,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
class Post
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private int $userId,
|
|
||||||
private string $title,
|
|
||||||
private string $body,
|
|
||||||
private DateTimeImmutable $createdAt,
|
|
||||||
private ?int $featureSlot,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): int
|
|
||||||
{
|
|
||||||
return $this->userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTitle(): string
|
|
||||||
{
|
|
||||||
return $this->title;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBody(): string
|
|
||||||
{
|
|
||||||
return $this->body;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFeatureSlot(): ?int
|
|
||||||
{
|
|
||||||
return $this->featureSlot;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isFeatured(): bool
|
|
||||||
{
|
|
||||||
return $this->featureSlot !== null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $user_id
|
|
||||||
* @property string $title
|
|
||||||
* @property string $body
|
|
||||||
* @property DateTimeImmutable $created_at
|
|
||||||
* @property ?int $feature_slot
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|PostModel newModelQuery()
|
|
||||||
* @method static Builder<static>|PostModel newQuery()
|
|
||||||
* @method static Builder<static>|PostModel query()
|
|
||||||
* @method static Builder<static>|PostModel whereId($value)
|
|
||||||
* @method static Builder<static>|PostModel whereUserId($value)
|
|
||||||
* @method static Builder<static>|PostModel whereTitle($value)
|
|
||||||
* @method static Builder<static>|PostModel whereBody($value)
|
|
||||||
* @method static Builder<static>|PostModel whereCreatedAt($value)
|
|
||||||
* @method static Builder<static>|PostModel whereFeatureSlot($value)
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class PostModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'posts';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'user_id',
|
|
||||||
'title',
|
|
||||||
'body',
|
|
||||||
'created_at',
|
|
||||||
'feature_slot',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'created_at' => 'immutable_datetime',
|
|
||||||
'feature_slot' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
interface PostRepository
|
|
||||||
{
|
|
||||||
public function create(CreatePostDto $dto): Post;
|
|
||||||
|
|
||||||
public function find(int $id): ?Post;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public function findByUserId(int $userId): array;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public function findRecent(int $limit): array;
|
|
||||||
|
|
||||||
public function delete(int $id): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws RuntimeException
|
|
||||||
*/
|
|
||||||
public function update(Post $post): Post;
|
|
||||||
|
|
||||||
public function findByFeatureSlot(int $slot): ?Post;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public function findFeatured(): array;
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\ClearFeaturedPost;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
|
|
||||||
class ClearFeaturedPost
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws ForbiddenException
|
|
||||||
*/
|
|
||||||
public function execute(ClearFeaturedPostRequest $request): void
|
|
||||||
{
|
|
||||||
if (! $request->requesterIsAdmin) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'only admins can unfeature a post'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ($request->postId <= 0) {
|
|
||||||
throw new BadRequestException('postId must be positive');
|
|
||||||
}
|
|
||||||
|
|
||||||
$post = $this->postRepo->find($request->postId);
|
|
||||||
if ($post === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! $post->isFeatured()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->postRepo->update(new Post(
|
|
||||||
id: $post->getId(),
|
|
||||||
userId: $post->getUserId(),
|
|
||||||
title: $post->getTitle(),
|
|
||||||
body: $post->getBody(),
|
|
||||||
createdAt: $post->getCreatedAt(),
|
|
||||||
featureSlot: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\ClearFeaturedPost;
|
|
||||||
|
|
||||||
class ClearFeaturedPostRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $postId,
|
|
||||||
public bool $requesterIsAdmin,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\CreatePost;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Post\CreatePostDto;
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
|
|
||||||
class CreatePost
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
private Clock $clock,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
public function execute(CreatePostRequest $request): Post
|
|
||||||
{
|
|
||||||
$title = $request->title === null ? '' : trim($request->title);
|
|
||||||
$body = $request->body === null ? '' : trim($request->body);
|
|
||||||
|
|
||||||
if ($title === '') {
|
|
||||||
throw new BadRequestException('title is required');
|
|
||||||
}
|
|
||||||
if ($body === '') {
|
|
||||||
throw new BadRequestException('body is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->postRepo->create(new CreatePostDto(
|
|
||||||
userId: $request->userId,
|
|
||||||
title: $title,
|
|
||||||
body: $body,
|
|
||||||
createdAt: $this->clock->now(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\CreatePost;
|
|
||||||
|
|
||||||
class CreatePostRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $userId,
|
|
||||||
public ?string $title,
|
|
||||||
public ?string $body,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\DeletePost;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
|
|
||||||
class DeletePost
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws ForbiddenException
|
|
||||||
*/
|
|
||||||
public function execute(DeletePostRequest $request): void
|
|
||||||
{
|
|
||||||
if ($request->postId <= 0) {
|
|
||||||
throw new BadRequestException('postId must be positive');
|
|
||||||
}
|
|
||||||
if ($request->requesterId <= 0) {
|
|
||||||
throw new BadRequestException('requesterId must be positive');
|
|
||||||
}
|
|
||||||
|
|
||||||
$post = $this->postRepo->find($request->postId);
|
|
||||||
if ($post === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$isAuthor = $post->getUserId() === $request->requesterId;
|
|
||||||
if (! $isAuthor && ! $request->requesterIsAdmin) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'requester is not allowed to delete this post'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->postRepo->delete($request->postId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\DeletePost;
|
|
||||||
|
|
||||||
class DeletePostRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $postId,
|
|
||||||
public int $requesterId,
|
|
||||||
public bool $requesterIsAdmin,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\GetPost;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
|
|
||||||
class GetPost
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
public function execute(int $id): ?Post
|
|
||||||
{
|
|
||||||
if ($id <= 0) {
|
|
||||||
throw new BadRequestException('id must be positive');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->postRepo->find($id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\ListFeaturedPosts;
|
|
||||||
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
|
|
||||||
class ListFeaturedPosts
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*/
|
|
||||||
public function execute(): array
|
|
||||||
{
|
|
||||||
return $this->postRepo->findFeatured();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\ListRecentPosts;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
|
|
||||||
class ListRecentPosts
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
public function execute(ListRecentPostsRequest $request): array
|
|
||||||
{
|
|
||||||
if ($request->limit <= 0) {
|
|
||||||
throw new BadRequestException('limit must be positive');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->postRepo->findRecent($request->limit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\ListRecentPosts;
|
|
||||||
|
|
||||||
class ListRecentPostsRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $limit,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\ListUserPosts;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
|
|
||||||
class ListUserPosts
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Post[]
|
|
||||||
*
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
public function execute(ListUserPostsRequest $request): array
|
|
||||||
{
|
|
||||||
if ($request->userId <= 0) {
|
|
||||||
throw new BadRequestException('userId must be positive');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->postRepo->findByUserId($request->userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\ListUserPosts;
|
|
||||||
|
|
||||||
class ListUserPostsRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $userId,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\SetFeaturedPost;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
use App\Post\Post;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class SetFeaturedPost
|
|
||||||
{
|
|
||||||
private const VALID_SLOTS = [1, 2];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private PostRepository $postRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws ForbiddenException
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function execute(SetFeaturedPostRequest $request): Post
|
|
||||||
{
|
|
||||||
if (! $request->requesterIsAdmin) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'only admins can feature a post'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ($request->postId <= 0) {
|
|
||||||
throw new BadRequestException('postId must be positive');
|
|
||||||
}
|
|
||||||
if (! in_array($request->slot, self::VALID_SLOTS, true)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'slot must be 1 or 2'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$post = $this->postRepo->find($request->postId);
|
|
||||||
if ($post === null) {
|
|
||||||
throw new DomainException('post not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingInSlot = $this->postRepo->findByFeatureSlot($request->slot);
|
|
||||||
if (
|
|
||||||
$existingInSlot !== null
|
|
||||||
&& $existingInSlot->getId() !== $post->getId()
|
|
||||||
) {
|
|
||||||
$this->postRepo->update(new Post(
|
|
||||||
id: $existingInSlot->getId(),
|
|
||||||
userId: $existingInSlot->getUserId(),
|
|
||||||
title: $existingInSlot->getTitle(),
|
|
||||||
body: $existingInSlot->getBody(),
|
|
||||||
createdAt: $existingInSlot->getCreatedAt(),
|
|
||||||
featureSlot: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->postRepo->update(new Post(
|
|
||||||
id: $post->getId(),
|
|
||||||
userId: $post->getUserId(),
|
|
||||||
title: $post->getTitle(),
|
|
||||||
body: $post->getBody(),
|
|
||||||
createdAt: $post->getCreatedAt(),
|
|
||||||
featureSlot: $request->slot,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Post\UseCases\SetFeaturedPost;
|
|
||||||
|
|
||||||
class SetFeaturedPostRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $postId,
|
|
||||||
public int $slot,
|
|
||||||
public bool $requesterIsAdmin,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Auth\BcryptPasswordHasher;
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Auth\PasswordHasher;
|
|
||||||
use App\Auth\RandomTokenGenerator;
|
|
||||||
use App\Auth\SystemClock;
|
|
||||||
use App\Auth\TokenGenerator;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Email\Emailer;
|
|
||||||
use App\Email\EmailFactory;
|
|
||||||
use App\Email\LaravelEmailFactory;
|
|
||||||
use App\Email\MailjetMailer;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUser;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use Illuminate\Contracts\Foundation\Application;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Mailjet\Client;
|
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
$this->app->bind(Clock::class, SystemClock::class);
|
|
||||||
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
|
||||||
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
|
||||||
$this->app->bind(Emailer::class, function (Application $app) {
|
|
||||||
return new MailjetMailer(
|
|
||||||
mailjet: new Client(
|
|
||||||
config('services.mailjet.key'),
|
|
||||||
config('services.mailjet.secret'),
|
|
||||||
true,
|
|
||||||
['version' => 'v3.1'],
|
|
||||||
),
|
|
||||||
fromName: config('mail.from.name'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
$this->app->bind(
|
|
||||||
EmailFactory::class,
|
|
||||||
function () {
|
|
||||||
return new LaravelEmailFactory(
|
|
||||||
confirmationUrlPrefix: config('app.frontend_url')
|
|
||||||
.'/confirm-email?token=',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
$this->app->bind(
|
|
||||||
SignupUser::class,
|
|
||||||
function (Application $app) {
|
|
||||||
return new SignupUser(
|
|
||||||
userRepo: $app->make(UserRepository::class),
|
|
||||||
tokenRepo: $app->make(
|
|
||||||
EmailConfirmationTokenRepository::class,
|
|
||||||
),
|
|
||||||
emailer: $app->make(Emailer::class),
|
|
||||||
emailFactory: $app->make(EmailFactory::class),
|
|
||||||
clock: $app->make(Clock::class),
|
|
||||||
fromAddress: config('mail.from.address'),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Auth\EloquentSessionRepository;
|
|
||||||
use App\Auth\SessionRepository;
|
|
||||||
use App\Comment\CommentRepository;
|
|
||||||
use App\Comment\EloquentCommentRepository;
|
|
||||||
use App\Email\EmailConfirmationToken\EloquentEmailConfirmationTokenRepository;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Post\EloquentPostRepository;
|
|
||||||
use App\Post\PostRepository;
|
|
||||||
use App\User\EloquentUserRepository;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
|
|
||||||
class RepositoryServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
$this->app->bind(
|
|
||||||
UserRepository::class,
|
|
||||||
EloquentUserRepository::class,
|
|
||||||
);
|
|
||||||
$this->app->bind(
|
|
||||||
SessionRepository::class,
|
|
||||||
EloquentSessionRepository::class,
|
|
||||||
);
|
|
||||||
$this->app->bind(
|
|
||||||
EmailConfirmationTokenRepository::class,
|
|
||||||
EloquentEmailConfirmationTokenRepository::class,
|
|
||||||
);
|
|
||||||
$this->app->bind(
|
|
||||||
PostRepository::class,
|
|
||||||
EloquentPostRepository::class,
|
|
||||||
);
|
|
||||||
$this->app->bind(
|
|
||||||
CommentRepository::class,
|
|
||||||
EloquentCommentRepository::class,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Shared\ValueObject;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final readonly class EmailAddress
|
|
||||||
{
|
|
||||||
private string $normalized;
|
|
||||||
|
|
||||||
private string $domain;
|
|
||||||
|
|
||||||
private const ERROR_MESSAGE = 'Invalid email address:';
|
|
||||||
|
|
||||||
public function __construct(string $email)
|
|
||||||
{
|
|
||||||
$trimmed = trim($email);
|
|
||||||
|
|
||||||
if ($trimmed === '' || ! str_contains($trimmed, '@')) {
|
|
||||||
throw new InvalidArgumentException(self::ERROR_MESSAGE." $email");
|
|
||||||
}
|
|
||||||
|
|
||||||
[$local, $domain] = explode('@', $trimmed, 2);
|
|
||||||
$this->domain = mb_strtolower($domain);
|
|
||||||
$normalized = $local.'@'.$this->domain;
|
|
||||||
|
|
||||||
if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) {
|
|
||||||
throw new InvalidArgumentException(self::ERROR_MESSAGE." $email");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->normalized = $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function value(): string
|
|
||||||
{
|
|
||||||
return $this->normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function equals(self $other): bool
|
|
||||||
{
|
|
||||||
return $this->normalized === $other->normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDomain(): string
|
|
||||||
{
|
|
||||||
return $this->domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __toString(): string
|
|
||||||
{
|
|
||||||
return $this->normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
readonly class CreateUserDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public EmailAddress $email,
|
|
||||||
public string $displayName,
|
|
||||||
public string $passwordHash,
|
|
||||||
public bool $isAdmin,
|
|
||||||
public ?DateTimeImmutable $emailConfirmedAt,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class EloquentUserRepository implements UserRepository
|
|
||||||
{
|
|
||||||
public function create(CreateUserDto $dto): User
|
|
||||||
{
|
|
||||||
$model = UserModel::create([
|
|
||||||
'email' => $dto->email->value(),
|
|
||||||
'display_name' => $dto->displayName,
|
|
||||||
'password_hash' => $dto->passwordHash,
|
|
||||||
'is_admin' => $dto->isAdmin,
|
|
||||||
'email_confirmed_at' => $dto->emailConfirmedAt,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): ?User
|
|
||||||
{
|
|
||||||
$model = UserModel::find($id);
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByEmail(EmailAddress $email): ?User
|
|
||||||
{
|
|
||||||
$model = UserModel::where('email', $email->value())->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByDisplayName(string $displayName): ?User
|
|
||||||
{
|
|
||||||
$model = UserModel::where('display_name', $displayName)->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return User[]
|
|
||||||
*/
|
|
||||||
public function search(string $query): array
|
|
||||||
{
|
|
||||||
$like = strtolower($query).'%';
|
|
||||||
$models = UserModel::query()
|
|
||||||
->whereRaw('LOWER(display_name) LIKE ?', [$like])
|
|
||||||
->orWhereRaw('LOWER(email) LIKE ?', [$like])
|
|
||||||
->orderBy('display_name')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $models->map(
|
|
||||||
function (UserModel $model) {
|
|
||||||
return $this->toDomain($model);
|
|
||||||
},
|
|
||||||
)->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws RuntimeException
|
|
||||||
*/
|
|
||||||
public function update(User $user): User
|
|
||||||
{
|
|
||||||
$model = UserModel::find($user->getId());
|
|
||||||
if ($model === null) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"User with id: {$user->getId()} does not exist"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$model->email = $user->getEmail()->value();
|
|
||||||
$model->display_name = $user->getDisplayName();
|
|
||||||
$model->password_hash = $user->getPasswordHash();
|
|
||||||
$model->is_admin = $user->isAdmin();
|
|
||||||
$model->email_confirmed_at = $user->getEmailConfirmedAt();
|
|
||||||
$model->save();
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(UserModel $model): User
|
|
||||||
{
|
|
||||||
$confirmedAt = null;
|
|
||||||
if ($model->email_confirmed_at !== null) {
|
|
||||||
$confirmedAt = new DateTimeImmutable(
|
|
||||||
$model->email_confirmed_at->toDateTimeString(),
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new User(
|
|
||||||
id: $model->id,
|
|
||||||
email: new EmailAddress($model->email),
|
|
||||||
displayName: $model->display_name,
|
|
||||||
passwordHash: $model->password_hash,
|
|
||||||
isAdmin: $model->is_admin,
|
|
||||||
emailConfirmedAt: $confirmedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\ConfirmUserEmail;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Auth\PasswordHasher;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class ConfirmUserEmail
|
|
||||||
{
|
|
||||||
private const MIN_PASSWORD_LENGTH = 8;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
private EmailConfirmationTokenRepository $tokenRepo,
|
|
||||||
private PasswordHasher $hasher,
|
|
||||||
private Clock $clock,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function execute(ConfirmUserEmailRequest $request): void
|
|
||||||
{
|
|
||||||
if ($request->token === null || $request->token === '') {
|
|
||||||
throw new BadRequestException('token is required');
|
|
||||||
}
|
|
||||||
if ($request->password === null || $request->password === '') {
|
|
||||||
throw new BadRequestException('password is required');
|
|
||||||
}
|
|
||||||
if (strlen($request->password) < self::MIN_PASSWORD_LENGTH) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'password must be at least '
|
|
||||||
.self::MIN_PASSWORD_LENGTH.' characters'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = $this->tokenRepo->findByToken($request->token);
|
|
||||||
if ($token === null) {
|
|
||||||
throw new DomainException('token not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = $this->clock->now();
|
|
||||||
if ($token->getAvailableTo() < $now) {
|
|
||||||
throw new DomainException('token expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $token->getUser();
|
|
||||||
$confirmedUser = new User(
|
|
||||||
id: $user->getId(),
|
|
||||||
email: $user->getEmail(),
|
|
||||||
displayName: $user->getDisplayName(),
|
|
||||||
passwordHash: $this->hasher->hash($request->password),
|
|
||||||
isAdmin: $user->isAdmin(),
|
|
||||||
emailConfirmedAt: $now,
|
|
||||||
);
|
|
||||||
$this->userRepo->update($confirmedUser);
|
|
||||||
$this->tokenRepo->delete($token->getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\ConfirmUserEmail;
|
|
||||||
|
|
||||||
class ConfirmUserEmailRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?string $token,
|
|
||||||
public ?string $password,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\PromoteUserToAdmin;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\ForbiddenException;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class PromoteUserToAdmin
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws ForbiddenException
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function execute(PromoteUserToAdminRequest $request): User
|
|
||||||
{
|
|
||||||
if (! $request->requesterIsAdmin) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'only admins can promote users'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ($request->targetUserId <= 0) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'targetUserId must be positive'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$target = $this->userRepo->find($request->targetUserId);
|
|
||||||
if ($target === null) {
|
|
||||||
throw new DomainException('user not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($target->isAdmin()) {
|
|
||||||
return $target;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->userRepo->update(new User(
|
|
||||||
id: $target->getId(),
|
|
||||||
email: $target->getEmail(),
|
|
||||||
displayName: $target->getDisplayName(),
|
|
||||||
passwordHash: $target->getPasswordHash(),
|
|
||||||
isAdmin: true,
|
|
||||||
emailConfirmedAt: $target->getEmailConfirmedAt(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\PromoteUserToAdmin;
|
|
||||||
|
|
||||||
class PromoteUserToAdminRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $targetUserId,
|
|
||||||
public bool $requesterIsAdmin,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\SearchUsers;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
|
|
||||||
class SearchUsers
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return User[]
|
|
||||||
*
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
public function execute(SearchUsersRequest $request): array
|
|
||||||
{
|
|
||||||
$query = trim($request->query);
|
|
||||||
if ($query === '') {
|
|
||||||
throw new BadRequestException('query is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->userRepo->search($query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\SearchUsers;
|
|
||||||
|
|
||||||
class SearchUsersRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public string $query,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\SignupUser;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Email\Emailer;
|
|
||||||
use App\Email\EmailFactory;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\CreateUserDto;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class SignupUser
|
|
||||||
{
|
|
||||||
private const DISPLAY_NAME_PATTERN = '/^[a-z0-9_-]{3,30}$/';
|
|
||||||
|
|
||||||
private const TOKEN_LIFETIME = '+1 day';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepo,
|
|
||||||
private EmailConfirmationTokenRepository $tokenRepo,
|
|
||||||
private Emailer $emailer,
|
|
||||||
private EmailFactory $emailFactory,
|
|
||||||
private Clock $clock,
|
|
||||||
private string $fromAddress,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function execute(SignupUserRequest $request): User
|
|
||||||
{
|
|
||||||
if ($request->email === null || $request->email === '') {
|
|
||||||
throw new BadRequestException('email is required');
|
|
||||||
}
|
|
||||||
if ($request->displayName === null || $request->displayName === '') {
|
|
||||||
throw new BadRequestException('displayName is required');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
preg_match(
|
|
||||||
self::DISPLAY_NAME_PATTERN,
|
|
||||||
$request->displayName,
|
|
||||||
) !== 1
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'displayName must be 3-30 chars of [a-z0-9_-]'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$email = new EmailAddress($request->email);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
throw new BadRequestException($exception->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userRepo->findByEmail($email) !== null) {
|
|
||||||
throw new DomainException('email already registered');
|
|
||||||
}
|
|
||||||
if ($this->userRepo->findByDisplayName($request->displayName) !== null) {
|
|
||||||
throw new DomainException('displayName already taken');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->userRepo->create(new CreateUserDto(
|
|
||||||
email: $email,
|
|
||||||
displayName: $request->displayName,
|
|
||||||
passwordHash: '',
|
|
||||||
isAdmin: false,
|
|
||||||
emailConfirmedAt: null,
|
|
||||||
));
|
|
||||||
|
|
||||||
$token = $this->tokenRepo->create(new CreateEmailConfirmationTokenDto(
|
|
||||||
user: $user,
|
|
||||||
availableTo: $this->clock->now()->modify(self::TOKEN_LIFETIME),
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->emailer->send(
|
|
||||||
$this->fromAddress,
|
|
||||||
$user->getEmail()->value(),
|
|
||||||
$this->emailFactory->makeConfirmationEmail($token->getToken()),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\SignupUser;
|
|
||||||
|
|
||||||
class SignupUserRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?string $email,
|
|
||||||
public ?string $displayName,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
class User
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private EmailAddress $email,
|
|
||||||
private string $displayName,
|
|
||||||
private string $passwordHash,
|
|
||||||
private bool $isAdmin,
|
|
||||||
private ?DateTimeImmutable $emailConfirmedAt,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEmail(): EmailAddress
|
|
||||||
{
|
|
||||||
return $this->email;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDisplayName(): string
|
|
||||||
{
|
|
||||||
return $this->displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPasswordHash(): string
|
|
||||||
{
|
|
||||||
return $this->passwordHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isAdmin(): bool
|
|
||||||
{
|
|
||||||
return $this->isAdmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEmailConfirmedAt(): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->emailConfirmedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isEmailConfirmed(): bool
|
|
||||||
{
|
|
||||||
return $this->emailConfirmedAt !== null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $email
|
|
||||||
* @property string $display_name
|
|
||||||
* @property string $password_hash
|
|
||||||
* @property bool $is_admin
|
|
||||||
* @property ?DateTimeImmutable $email_confirmed_at
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|UserModel newModelQuery()
|
|
||||||
* @method static Builder<static>|UserModel newQuery()
|
|
||||||
* @method static Builder<static>|UserModel query()
|
|
||||||
* @method static Builder<static>|UserModel whereId($value)
|
|
||||||
* @method static Builder<static>|UserModel whereEmail($value)
|
|
||||||
* @method static Builder<static>|UserModel whereDisplayName($value)
|
|
||||||
* @method static Builder<static>|UserModel whereIsAdmin($value)
|
|
||||||
* @method static Builder<static>|UserModel whereEmailConfirmedAt($value)
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class UserModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'users';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'email',
|
|
||||||
'display_name',
|
|
||||||
'password_hash',
|
|
||||||
'is_admin',
|
|
||||||
'email_confirmed_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'is_admin' => 'boolean',
|
|
||||||
'email_confirmed_at' => 'immutable_datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
|
|
||||||
interface UserRepository
|
|
||||||
{
|
|
||||||
public function create(CreateUserDto $dto): User;
|
|
||||||
|
|
||||||
public function find(int $id): ?User;
|
|
||||||
|
|
||||||
public function findByEmail(EmailAddress $email): ?User;
|
|
||||||
|
|
||||||
public function findByDisplayName(string $displayName): ?User;
|
|
||||||
|
|
||||||
public function update(User $user): User;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return User[]
|
|
||||||
*/
|
|
||||||
public function search(string $query): array;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
|
||||||
|
|
||||||
// Register the Composer autoloader...
|
|
||||||
require __DIR__.'/vendor/autoload.php';
|
|
||||||
|
|
||||||
// Bootstrap Laravel and handle the command...
|
|
||||||
/** @var Application $app */
|
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
|
||||||
|
|
||||||
$status = $app->handleCommand(new ArgvInput);
|
|
||||||
|
|
||||||
exit($status);
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
|
||||||
use Illuminate\Http\Middleware\HandleCors;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
|
||||||
->withRouting(
|
|
||||||
web: __DIR__.'/../routes/web.php',
|
|
||||||
api: __DIR__.'/../routes/api.php',
|
|
||||||
commands: __DIR__.'/../routes/console.php',
|
|
||||||
health: '/up',
|
|
||||||
)
|
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
|
||||||
$middleware->append(HandleCors::class);
|
|
||||||
})
|
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
|
||||||
//
|
|
||||||
})->create();
|
|
||||||
2
backend/bootstrap/cache/.gitignore
vendored
2
backend/bootstrap/cache/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
|
||||||
use App\Providers\RepositoryServiceProvider;
|
|
||||||
|
|
||||||
return [
|
|
||||||
AppServiceProvider::class,
|
|
||||||
RepositoryServiceProvider::class,
|
|
||||||
];
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://getcomposer.org/schema.json",
|
|
||||||
"name": "tide/backend",
|
|
||||||
"type": "project",
|
|
||||||
"description": "TIDE blogging app backend.",
|
|
||||||
"keywords": ["laravel", "framework", "tide", "blog"],
|
|
||||||
"license": "MIT",
|
|
||||||
"require": {
|
|
||||||
"php": "^8.4",
|
|
||||||
"laravel/framework": "^12.0",
|
|
||||||
"laravel/tinker": "^2.10.1",
|
|
||||||
"mailjet/mailjet-apiv3-php": "^1.6"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"barryvdh/laravel-ide-helper": "^3.7",
|
|
||||||
"fakerphp/faker": "^1.23",
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.91",
|
|
||||||
"jubeki/laravel-code-style": "^2.18",
|
|
||||||
"larastan/larastan": "^3.0",
|
|
||||||
"laravel/pail": "^1.2.2",
|
|
||||||
"laravel/pint": "^1.24",
|
|
||||||
"laravel/sail": "^1.41",
|
|
||||||
"mockery/mockery": "^1.6",
|
|
||||||
"nunomaduro/collision": "^8.8",
|
|
||||||
"phpstan/phpstan": "^2.1",
|
|
||||||
"phpstan/phpstan-strict-rules": "^2.0",
|
|
||||||
"phpunit/phpunit": "^11.5.3"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "app/",
|
|
||||||
"Database\\Factories\\": "database/factories/",
|
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"setup": [
|
|
||||||
"composer install",
|
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
|
||||||
"@php artisan key:generate",
|
|
||||||
"@php artisan migrate --force"
|
|
||||||
],
|
|
||||||
"dev": [
|
|
||||||
"Composer\\Config::disableProcessTimeout",
|
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" --names=server,queue,logs --kill-others"
|
|
||||||
],
|
|
||||||
|
|
||||||
"stan": "phpstan analyse --no-progress --memory-limit=512M",
|
|
||||||
"cs:fix": "php-cs-fixer fix",
|
|
||||||
"cs:check": "php-cs-fixer check --diff -vvv",
|
|
||||||
|
|
||||||
"test": [
|
|
||||||
"@php artisan config:clear --ansi",
|
|
||||||
"@php artisan test"
|
|
||||||
],
|
|
||||||
"post-autoload-dump": [
|
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
|
||||||
"@php artisan package:discover --ansi"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
|
||||||
],
|
|
||||||
"post-root-package-install": [
|
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
|
||||||
],
|
|
||||||
"post-create-project-cmd": [
|
|
||||||
"@php artisan key:generate --ansi",
|
|
||||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
|
||||||
"@php artisan migrate --graceful --ansi"
|
|
||||||
],
|
|
||||||
"pre-package-uninstall": [
|
|
||||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"dont-discover": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"preferred-install": "dist",
|
|
||||||
"sort-packages": true,
|
|
||||||
"allow-plugins": {
|
|
||||||
"pestphp/pest-plugin": true,
|
|
||||||
"php-http/discovery": true,
|
|
||||||
"phpstan/extension-installer": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"minimum-stability": "stable",
|
|
||||||
"prefer-stable": true
|
|
||||||
}
|
|
||||||
10451
backend/composer.lock
generated
10451
backend/composer.lock
generated
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue