Documents the deterministic guard-rail layer: hooks reference, crewli-reviewer subagent usage, three slash commands, how to test hooks, how to disable temporarily, and the binding design principle (settings/hooks deterministic, CLAUDE.md advisory — never duplicate).
374 lines
17 KiB
Markdown
374 lines
17 KiB
Markdown
# Crewli — Claude Code Instructions
|
||
|
||
> See `dev-docs/CLAUDE_CODE_TOOLING.md` for the deterministic guard-rail layer (hooks, subagent, slash commands).
|
||
|
||
## Project context
|
||
|
||
Crewli is a multi-tenant SaaS platform for event and festival management.
|
||
Built for a professional volunteer organisation, with potential to expand as SaaS.
|
||
Design document: `/dev-docs/design-document.md`
|
||
|
||
## Tech stack
|
||
|
||
- Backend: PHP 8.2+, Laravel 12, Sanctum, Spatie Permission, MySQL 8, Redis
|
||
- Frontend: TypeScript, Vue 3 (Composition API), Vuexy/Vuetify, Pinia, TanStack Query
|
||
- Testing: PHPUnit (backend), Vitest (frontend)
|
||
|
||
## Quality gates
|
||
|
||
- `php artisan test` — PHPUnit feature suite. See Testing rules below.
|
||
- `./vendor/bin/pint` — code style. Runs pre-commit in scope; format before merge.
|
||
- `composer analyse` — Larastan static analysis at level 6 with
|
||
accept-all baseline. New errors beyond the baseline must be fixed
|
||
before merge. See `/dev-docs/LARASTAN.md`.
|
||
- `composer rector` — Rector dry-run for modernisation suggestions.
|
||
See `/dev-docs/RECTOR.md`. Apply only in scoped sprints, never
|
||
automatically.
|
||
- ts-reset patches TypeScript's loosest default types in both SPAs.
|
||
See `/dev-docs/FRONTEND-TOOLING.md`. New TypeScript code adheres
|
||
to ts-reset's stricter types automatically.
|
||
- Vitest — `apps/portal` has 113+ tests; `apps/app` currently has
|
||
no Vitest setup (tracked as TECH-APP-VITEST, must close before
|
||
S3b lands).
|
||
|
||
## Development tooling
|
||
|
||
- Laravel Telescope at `/telescope` — queries, jobs, mails, redis,
|
||
events. Local + testing only, never production. super_admin role
|
||
required to view. See `/dev-docs/TELESCOPE.md`.
|
||
|
||
## Repository layout
|
||
|
||
- `api/` — Laravel backend
|
||
- `apps/app/` — Organizer SPA (main product app + Platform Admin for super admins)
|
||
- `apps/portal/` — External portal (volunteers, artists, suppliers, etc.)
|
||
|
||
## Apps and portal architecture
|
||
|
||
- `apps/app/` — Organizer: event management per organisation. Includes **Platform Admin** section (`/platform/*`) for super_admin users (organisation management, user management, impersonation, activity log).
|
||
- `apps/portal/` — External users: one app, two access modes:
|
||
- Login-based (`auth:sanctum`): volunteers, crew — persons with `user_id`
|
||
- Token-based (`portal.token` middleware): artists, suppliers, press — persons without `user_id`
|
||
|
||
### CORS
|
||
|
||
Configure two frontend origins in both Laravel (`config/cors.php` via env) and the Vite dev server proxy:
|
||
|
||
- app: `localhost:5174`
|
||
- portal: `localhost:5175`
|
||
|
||
**Production (`crewli.app`):** API `https://api.crewli.app`, SPAs `https://crewli.app`, `https://portal.crewli.app` — see `api/.env.example` for `FRONTEND_*` and `SANCTUM_STATEFUL_DOMAINS`. **`crewli.nl`** is only for a future marketing site; this application stack uses **`crewli.app`** (not `.nl` for API, SPAs, or transactional mail).
|
||
|
||
## Backend rules (strict)
|
||
|
||
### Multi-tenancy
|
||
|
||
- Every query on event data **must** scope on `organisation_id`
|
||
- Use `OrganisationScope` as an Eloquent global scope on all event-related models
|
||
- Never use raw ID checks in controllers — always use policies
|
||
|
||
### Controllers
|
||
|
||
- Use resource controllers (`index` / `show` / `store` / `update` / `destroy`)
|
||
- Namespace: `App\Http\Controllers\Api\V1\`
|
||
- Return all responses through API resources (never return raw model attributes)
|
||
- Validate with form requests (never inline `validate()`)
|
||
|
||
### Models
|
||
|
||
- Use the `HasUlids` trait on all business models (not UUID v4)
|
||
- Soft deletes on: Organisation, Event, FestivalSection, Shift, ShiftAssignment, Person, Artist
|
||
- No soft deletes on: CheckIn, BriefingSend, MessageReply, ShiftWaitlist (audit records)
|
||
- JSON columns **only** for opaque configuration — never for queryable/filterable data
|
||
|
||
### Database
|
||
|
||
Crewli uses **MySQL 8.0 exclusively** across all environments:
|
||
|
||
- **Local development**: MySQL 8.0 via `docker-compose.yml` (`bm_mysql` container, port 3306). Run `make services` to start.
|
||
- **Testing**: MySQL 8.0 via the same Docker container, separate `crewli_test` database. Run `make test-db-create` once, then `make test`.
|
||
- **Staging/production**: MySQL 8.0 on the deployment VPS.
|
||
|
||
**SQLite is forbidden** in all environments. Reasons:
|
||
|
||
- SQLite has known quirks around foreign-key constraints (rebuild-on-FK-add cascades), JSON queries (key-order non-determinism on round-trip differs from MySQL), and concurrent writes that mask bugs which MySQL would surface in production
|
||
- Cross-database query syntax differences cause silent test/production drift (e.g. `sqlite_master` vs. `information_schema`, VARCHAR length enforcement)
|
||
- Crewli's enterprise-grade product philosophy doesn't tolerate "but it works on SQLite" as a passing test
|
||
|
||
If a contributor finds SQLite references in `phpunit.xml`, `.env.testing`, or any config file, treat it as a regression and fix immediately.
|
||
|
||
When writing migrations or queries:
|
||
|
||
- Use Laravel's query builder where possible — it handles cross-DB syntax consistently
|
||
- For raw SQL, write MySQL syntax (no need for SQLite fallbacks). Index introspection: query `information_schema.STATISTICS`, never `sqlite_master`.
|
||
- Foreign keys go on every relation column. The `nullOnDelete` / `cascadeOnDelete` choice is per the relationship's domain semantics; "no FK" is not an option.
|
||
- JSON values stored in byte-stable columns (`schema_snapshot`, webhook `payload_snapshot`, activity-log `properties` for diff payloads) MUST be canonicalized at write via `App\Support\Json\JsonCanonicalizer`. MySQL JSON columns may reorder associative-array keys on round-trip; canonicalize so re-emits / HMAC signatures / audit-replay diffs are byte-identical. Opaque-config JSON (`form_schemas.settings`, `translations`) is exempt — key order has no semantic meaning there. Tests on canonicalized data use `assertSame(JsonCanonicalizer::encode($a), JsonCanonicalizer::encode($b))`.
|
||
|
||
Other database rules:
|
||
|
||
- Primary keys: ULID via `HasUlids` (not UUID v4, not auto-increment on business tables)
|
||
- Create migrations in dependency order: foundation first, then dependent tables
|
||
- Always add composite indexes as documented in the design document (section 3.5)
|
||
|
||
### Schema dumps (CI fast path)
|
||
|
||
Laravel uses `database/schema/{driver}-schema.sql` as a fast-path
|
||
baseline that `migrate:fresh` loads in one statement (~1s) instead of
|
||
replaying every migration (~6s × N migrations). For Crewli's backfill
|
||
/ migration tests that call `migrate:fresh` per test, this drops the
|
||
4 backfill-test classes from ~128s to ~28s combined (78% reduction).
|
||
|
||
The dump is **committed** at `api/database/schema/mysql-schema.sql`
|
||
and is the default code path. CI loads it automatically via
|
||
`migrate:fresh`.
|
||
|
||
**One-time host setup** (required for both generating and loading
|
||
the dump):
|
||
|
||
```bash
|
||
brew install mysql-client
|
||
echo 'export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"' >> ~/.zshrc
|
||
source ~/.zshrc
|
||
```
|
||
|
||
Verify with `which mysql && which mysqldump`. Both must resolve to a
|
||
real binary; Laravel's `schema:dump` and `migrate:fresh` schema-load
|
||
both shell out to these.
|
||
|
||
**After adding a new migration:**
|
||
|
||
```bash
|
||
make schema-dump
|
||
git add api/database/schema/mysql-schema.sql
|
||
```
|
||
|
||
`make schema-dump` brings `crewli_test` to head and runs
|
||
`php artisan schema:dump`. Commit the regenerated file in the same
|
||
PR as the migration.
|
||
|
||
`--prune` is NOT used: individual migration files stay readable in
|
||
`api/database/migrations/` for audit / rollback purposes.
|
||
|
||
### Roles and permissions
|
||
|
||
- Use Spatie `laravel-permission`
|
||
- Check roles via `$user->hasRole()` and policies — never hard-code role strings in controllers
|
||
- Three levels: app (`super_admin`), organisation (`org_admin` / `org_member`), event (`event_manager`, etc.)
|
||
|
||
### Testing
|
||
|
||
- Write PHPUnit feature tests per controller
|
||
- Minimum per endpoint: happy path + unauthenticated (401) + wrong organisation (403)
|
||
- Use factories for all test data
|
||
- After each module: `php artisan test --filter=ModuleName`
|
||
|
||
## Frontend rules (strict)
|
||
|
||
### Vuexy reference source (mandatory)
|
||
|
||
When referencing Vuexy demo pages, components, or patterns, ALWAYS use the TypeScript Vue version at:
|
||
|
||
```
|
||
resources/vuexy-admin-v10.11.1/vue-version/typescript-version/full-version/
|
||
```
|
||
|
||
This is the **ONLY** valid reference path. Never use:
|
||
- `javascript-version/` — wrong language
|
||
- `starter-kit/` — incomplete, missing components
|
||
- Any other variant or version
|
||
|
||
Before implementing any Vuexy-based page or component, read the reference implementation from this path first:
|
||
```bash
|
||
# Example: find auth page references
|
||
find resources/vuexy-admin-v10.11.1/vue-version/typescript-version/full-version/src/pages -name "*.vue" | grep -i "login\|auth"
|
||
```
|
||
|
||
### Vuexy-first strategy
|
||
|
||
Before writing ANY frontend component, consult `/dev-docs/VUEXY_COMPONENTS.md` and follow this decision tree:
|
||
|
||
1. **Can a standard Vuetify component do this?** → Use it with default props.
|
||
Do not wrap it in a custom component.
|
||
2. **Does Vuexy provide an @core component for this?** → Use it. Check
|
||
`/dev-docs/VUEXY_COMPONENTS.md` section 1 for the full registry.
|
||
3. **Does an existing Crewli page already solve a similar UI pattern?** →
|
||
Copy that pattern exactly. Check `/dev-docs/VUEXY_COMPONENTS.md` section 3
|
||
for established patterns and their reference implementations.
|
||
4. **None of the above?** → Only then write custom code. Add `<style scoped>`
|
||
with a comment explaining why Vuexy/Vuetify couldn't handle it.
|
||
|
||
Concrete component rules:
|
||
- Tables: `v-data-table-server` with server-side pagination — never client-side for API data
|
||
- Cards: `v-card` directly, or `AppCardActions` when collapse/refresh/remove is needed
|
||
- Forms in dialogs: `v-dialog` + `v-card` + `v-form` — follow the established dialog pattern
|
||
- Detail panels: `v-navigation-drawer` with `temporary` and `location="end"` — follow ShiftDetailPanel pattern
|
||
- Date/time pickers: `AppDateTimePicker` from @core — never raw input[type=date]
|
||
- Status indicators: `v-chip` with color prop — never custom styled spans
|
||
- Loading states: `v-skeleton-loader` — never custom spinners
|
||
- Error states: `v-alert` with retry button — never custom error divs
|
||
- Empty states: `v-card` with icon + message + action button
|
||
- Notifications: `v-snackbar` — never custom toast components
|
||
- Page layout: `v-row` + `v-col` with Vuetify breakpoint props — never CSS grid or custom flexbox
|
||
|
||
**Before ANY frontend task:** read `/dev-docs/VUEXY_COMPONENTS.md` to verify
|
||
you are using available components rather than building custom ones.
|
||
|
||
### Vue components
|
||
|
||
- Always `<script setup lang="ts">` — never the Options API
|
||
- Type props with `defineProps<{...}>()`
|
||
- Declare emits with `defineEmits<{...}>()`
|
||
|
||
### API calls
|
||
|
||
- Use TanStack Query (`useQuery` / `useMutation`) for **all** API calls
|
||
- Never call axios directly from a component — always via a composable under `composables/api/use[Module].ts`
|
||
- `src/lib/axios.ts` is the canonical axios instance — the only place axios is imported directly
|
||
- Use Pinia stores for cross-component state — no prop drilling
|
||
|
||
### TypeScript
|
||
|
||
- No `any` types — ever. Every variable, prop, emit, return type, ref, computed must be fully typed
|
||
- Types first: create `src/types/[module].ts` before composables or components
|
||
- Mirror backend PHP Enums as `as const` objects in `src/types/`:
|
||
```typescript
|
||
export const ShiftStatus = { PENDING: 'pending', APPROVED: 'approved' } as const
|
||
export type ShiftStatus = typeof ShiftStatus[keyof typeof ShiftStatus]
|
||
```
|
||
|
||
### Forms
|
||
|
||
- VeeValidate for form state + Zod for schema validation — always together
|
||
- Zod schemas must mirror the backend Form Request rules (field names, required/optional, types)
|
||
- No inline validation logic in components
|
||
|
||
### Naming
|
||
|
||
- DB columns: `snake_case`
|
||
- TypeScript / JS variables: `camelCase`
|
||
- Vue components: PascalCase (e.g. `ShiftAssignPanel.vue`)
|
||
- Composables: `use` prefix (e.g. `useShifts.ts`)
|
||
- Pinia stores: `use` prefix + `Store` suffix (e.g. `useEventStore.ts`)
|
||
|
||
### UI
|
||
|
||
- Always use Vuexy/Vuetify for layout, forms, tables, dialogs
|
||
- Do not write custom CSS when a Vuetify utility class exists
|
||
- Responsive: mobile-first, usable from 375px width
|
||
- **Three states per page:** every data-driven view must handle loading (skeleton/spinner), error (`v-alert` with retry button), and empty (helpful message with action button)
|
||
- Use Vuetify responsive props (`cols`, `sm`, `md`, `lg`) — no fixed pixel widths
|
||
- Custom CSS via `<style scoped>` only as last resort when no Vuetify utility exists
|
||
|
||
## Forbidden patterns
|
||
|
||
- Never: `$user->role === 'admin'` (use policies)
|
||
- Never: `Model::all()` without a `where` clause (always scope)
|
||
- Never: leave `dd()` or `var_dump()` in code
|
||
- Never: hard-code `.env` values in code
|
||
- Never: use JSON columns for data you need to filter on
|
||
- Never: UUID v4 as primary key (use `HasUlids`)
|
||
- Never: TypeScript `any` type (use proper types, generics, or `unknown` with type guards)
|
||
- Never: import axios directly in a component (use `src/lib/axios.ts` via a composable)
|
||
|
||
## Frontend import boundaries (apps/app/)
|
||
|
||
`apps/app/` enforces a layered import architecture via
|
||
`eslint-plugin-boundaries`. Ten zones (`types` → `utils` → `lib` →
|
||
`plugins` / `composables` / `stores` / `navigation` → `components` →
|
||
`layouts` → `pages`); each zone may only import from the zones below
|
||
it in the matrix. Vendored `@core/` and `@layouts/` are exempt.
|
||
Cross-zone violations are lint errors, not warnings.
|
||
|
||
Matrix details + rationale: `dev-docs/WS-3-SESSION-1C-AUDIT.md`.
|
||
Config: `apps/app/.eslintrc.cjs`.
|
||
|
||
When adding a new file: pick the zone first. If your file imports
|
||
from a zone the matrix forbids, the structural answer is usually to
|
||
hoist a type to `types/` or extract a helper to `utils/` /
|
||
`composables/` — not to disable the rule. Per-line disables are
|
||
allowed only with a `TODO TECH-*` reference to a backlog item.
|
||
|
||
## Order of work for each new module
|
||
|
||
1. Create and run migration(s)
|
||
2. Eloquent model with relationships, scopes, and `HasUlids`
|
||
3. Factory for test data
|
||
4. Policy for authorization
|
||
5. Form request(s) for validation
|
||
6. API resource for response shaping
|
||
7. Resource controller
|
||
8. Register routes in `api.php`
|
||
9. Write and run PHPUnit feature tests
|
||
10. TypeScript types in `src/types/[module].ts`
|
||
11. API composable in `src/composables/api/use[Module].ts`
|
||
12. Pinia store in `src/stores/use[Module]Store.ts` (only if cross-component state is needed)
|
||
13. Vue page component in `src/pages/[module]/`
|
||
14. Add route in Vue Router
|
||
|
||
## User Documentation (VitePress)
|
||
|
||
End-user documentation lives in /docs/ as a VitePress site.
|
||
Developer documentation (SCHEMA.md, API.md, etc.) lives in /dev-docs/.
|
||
|
||
### When to write docs
|
||
|
||
When completing a feature that introduces or changes **user-facing behaviour**,
|
||
create or update the corresponding documentation page under /docs/.
|
||
|
||
### How to write docs
|
||
|
||
1. Read /docs/.templates/style-guide.md for terminology, tone, and structure rules
|
||
2. Use /docs/.templates/feature-page.md or concept-page.md as your starting template
|
||
3. Every page MUST have frontmatter with: title, description, tags
|
||
4. Use Dutch language for all content (informal "je/jij")
|
||
5. Use Crewli terminology from the style guide — never English equivalents in user docs
|
||
|
||
### What to include
|
||
|
||
- What the feature does (2-3 sentences)
|
||
- Step-by-step instructions from the user's perspective
|
||
- Which roles have access (table format)
|
||
- Screenshot placeholders where visual guidance helps: ``
|
||
- Links to related pages
|
||
|
||
### File placement
|
||
|
||
Match the existing structure under /docs/:
|
||
|
||
- Organizer features → /docs/organizer/[category]/
|
||
- Volunteer features → /docs/volunteer/
|
||
- Portal features → /docs/portal/
|
||
- General concepts → /docs/guide/
|
||
|
||
## Git Commit Policy
|
||
|
||
**After every successful task completion, commit the changes immediately.**
|
||
|
||
Rules:
|
||
1. After all tests pass and build succeeds, stage and commit the changed files
|
||
2. Use conventional commit messages: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`
|
||
3. One commit per logical unit of work (one feature, one bugfix, one refactor)
|
||
4. Never bundle unrelated changes in a single commit
|
||
5. Never commit with failing tests
|
||
6. Do NOT push automatically — only commit locally. The developer will push manually.
|
||
|
||
Commit message format:
|
||
```
|
||
feat: short description of the feature
|
||
fix: short description of the bug fix
|
||
refactor: short description of the refactoring
|
||
docs: short description of documentation changes
|
||
test: short description of test additions
|
||
```
|
||
|
||
- After a feature branch is merged into main, delete it locally
|
||
(`git branch -d <branch>`) and remotely (`git push origin --delete
|
||
<branch>`) immediately. Stale feature branches accumulate cognitive
|
||
load and obscure SHA-level history when squash-merges leave commit
|
||
hashes orphaned on the original branch.
|
||
|
||
Examples:
|
||
- `feat: person tags system with org-level skills and sync endpoint`
|
||
- `fix: auth race condition on page refresh`
|
||
- `docs: update SCHEMA.md with person_identity_matches table`
|