replace app with discourse module

This commit is contained in:
Yisroel Baum 2026-07-19 10:29:50 +03:00
parent 63a68f8ae0
commit 95cdeaedfa
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
236 changed files with 357 additions and 31169 deletions

9
.envrc
View file

@ -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
View file

@ -1,9 +1,3 @@
# Nix devshell artefacts
.postgres/
.direnv/ .direnv/
result result
result-* result-*
# Process-compose state
.pc.*
process-compose-*.log

View file

@ -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
View 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
View 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
```

View file

@ -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.

View file

@ -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`.

View file

@ -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.

View file

@ -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

View file

@ -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}"

View file

@ -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
View file

@ -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

View file

@ -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'],
],
]);

View file

@ -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).

View file

@ -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);
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
interface Clock
{
public function now(): DateTimeImmutable;
}

View file

@ -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,
) {}
}

View file

@ -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();
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace App\Auth;
interface PasswordHasher
{
public function hash(string $password): string;
public function verify(string $password, string $hash): bool;
}

View file

@ -1,11 +0,0 @@
<?php
namespace App\Auth;
class RandomTokenGenerator implements TokenGenerator
{
public function generate(): string
{
return bin2hex(random_bytes(32));
}
}

View file

@ -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;
}
}

View file

@ -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',
];
}

View file

@ -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;
}

View file

@ -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'));
}
}

View file

@ -1,8 +0,0 @@
<?php
namespace App\Auth;
interface TokenGenerator
{
public function generate(): string;
}

View file

@ -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;
}
}

View file

@ -1,11 +0,0 @@
<?php
namespace App\Auth\UseCases\AuthenticateUser;
class AuthenticateUserRequest
{
public function __construct(
public ?string $email,
public ?string $password,
) {}
}

View file

@ -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,
));
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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',
];
}

View file

@ -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;
}

View file

@ -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,
) {}
}

View file

@ -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,
),
);
}
}

View file

@ -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(),
));
}
}

View file

@ -1,12 +0,0 @@
<?php
namespace App\Comment\UseCases\CreateComment;
class CreateCommentRequest
{
public function __construct(
public int $postId,
public int $userId,
public ?string $body,
) {}
}

View file

@ -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);
}
}

View file

@ -1,12 +0,0 @@
<?php
namespace App\Comment\UseCases\DeleteComment;
class DeleteCommentRequest
{
public function __construct(
public int $commentId,
public int $requesterId,
public bool $requesterIsAdmin,
) {}
}

View file

@ -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);
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace App\Comment\UseCases\ListCommentsForPost;
class ListCommentsForPostRequest
{
public function __construct(
public int $postId,
) {}
}

View file

@ -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;
}
}

View file

@ -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(),
];
}
}

View file

@ -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),
];
}
}

View file

@ -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(),
];
}
}

View file

@ -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);
}
}

View file

@ -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,
) {}
}

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -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',
];
}

View file

@ -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;
}

View file

@ -1,8 +0,0 @@
<?php
namespace App\Email;
interface EmailFactory
{
public function makeConfirmationEmail(string $token): string;
}

View file

@ -1,8 +0,0 @@
<?php
namespace App\Email;
interface Emailer
{
public function send(string $from, string $to, string $body): void;
}

View file

@ -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}";
}
}

View file

@ -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');
}
);
}
}

View file

@ -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,
],
],
],
]);
}
}

View file

@ -1,7 +0,0 @@
<?php
namespace App\Exceptions;
use DomainException;
class BadRequestException extends DomainException {}

View file

@ -1,7 +0,0 @@
<?php
namespace App\Exceptions;
use DomainException;
class ForbiddenException extends DomainException {}

View file

@ -1,7 +0,0 @@
<?php
namespace App\Exceptions;
use DomainException;
class UnauthorizedException extends DomainException {}

View file

@ -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);
}
}

View file

@ -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,
) {}
}

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -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',
];
}

View file

@ -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;
}

View file

@ -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,
));
}
}

View file

@ -1,11 +0,0 @@
<?php
namespace App\Post\UseCases\ClearFeaturedPost;
class ClearFeaturedPostRequest
{
public function __construct(
public int $postId,
public bool $requesterIsAdmin,
) {}
}

View file

@ -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(),
));
}
}

View file

@ -1,12 +0,0 @@
<?php
namespace App\Post\UseCases\CreatePost;
class CreatePostRequest
{
public function __construct(
public int $userId,
public ?string $title,
public ?string $body,
) {}
}

View file

@ -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);
}
}

View file

@ -1,12 +0,0 @@
<?php
namespace App\Post\UseCases\DeletePost;
class DeletePostRequest
{
public function __construct(
public int $postId,
public int $requesterId,
public bool $requesterIsAdmin,
) {}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace App\Post\UseCases\ListRecentPosts;
class ListRecentPostsRequest
{
public function __construct(
public int $limit,
) {}
}

View file

@ -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);
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace App\Post\UseCases\ListUserPosts;
class ListUserPostsRequest
{
public function __construct(
public int $userId,
) {}
}

View file

@ -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,
));
}
}

View file

@ -1,12 +0,0 @@
<?php
namespace App\Post\UseCases\SetFeaturedPost;
class SetFeaturedPostRequest
{
public function __construct(
public int $postId,
public int $slot,
public bool $requesterIsAdmin,
) {}
}

View file

@ -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
{
//
}
}

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -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,
) {}
}

View file

@ -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,
);
}
}

View file

@ -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());
}
}

View file

@ -1,11 +0,0 @@
<?php
namespace App\User\UseCases\ConfirmUserEmail;
class ConfirmUserEmailRequest
{
public function __construct(
public ?string $token,
public ?string $password,
) {}
}

View file

@ -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(),
));
}
}

View file

@ -1,11 +0,0 @@
<?php
namespace App\User\UseCases\PromoteUserToAdmin;
class PromoteUserToAdminRequest
{
public function __construct(
public int $targetUserId,
public bool $requesterIsAdmin,
) {}
}

View file

@ -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);
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace App\User\UseCases\SearchUsers;
class SearchUsersRequest
{
public function __construct(
public string $query,
) {}
}

View file

@ -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;
}
}

View file

@ -1,11 +0,0 @@
<?php
namespace App\User\UseCases\SignupUser;
class SignupUserRequest
{
public function __construct(
public ?string $email,
public ?string $displayName,
) {}
}

View file

@ -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;
}
}

View file

@ -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',
];
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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();

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,9 +0,0 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\RepositoryServiceProvider;
return [
AppServiceProvider::class,
RepositoryServiceProvider::class,
];

View file

@ -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

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