diff --git a/.claude/commands/openspec/apply.md b/.claude/commands/openspec/apply.md new file mode 100644 index 0000000..a36fd96 --- /dev/null +++ b/.claude/commands/openspec/apply.md @@ -0,0 +1,23 @@ +--- +name: OpenSpec: Apply +description: Implement an approved OpenSpec change and keep tasks in sync. +category: OpenSpec +tags: [openspec, apply] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.claude/commands/openspec/archive.md b/.claude/commands/openspec/archive.md new file mode 100644 index 0000000..9c2ce7a --- /dev/null +++ b/.claude/commands/openspec/archive.md @@ -0,0 +1,27 @@ +--- +name: OpenSpec: Archive +description: Archive a deployed OpenSpec change and update specs. +category: OpenSpec +tags: [openspec, archive] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict --no-interactive` and inspect with `openspec show ` if anything looks off. + +**Reference** +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.claude/commands/openspec/proposal.md b/.claude/commands/openspec/proposal.md new file mode 100644 index 0000000..a7563da --- /dev/null +++ b/.claude/commands/openspec/proposal.md @@ -0,0 +1,28 @@ +--- +name: OpenSpec: Proposal +description: Scaffold a new OpenSpec change and validate strictly. +category: OpenSpec +tags: [openspec, change] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict --no-interactive` and resolve every issue before sharing the proposal. + +**Reference** +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0669699 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + \ No newline at end of file diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 0000000..6c1703e --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,456 @@ +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## TL;DR Quick Checklist + +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues +- Request approval: Do not start implementation until proposal is approved + +## Three-Stage Workflow + +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict --no-interactive` and resolve any issues before sharing the proposal. + +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks + +## Before Any Task + +**Context Checklist:** +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities + +**Before Creating Specs:** +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1–2 clarifying questions before scaffolding + +### Search Guidance +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` + +## Quick Start + +### CLI Commands + +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec validate [item] # Validate changes or specs +openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) + +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files + +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict --no-interactive +``` + +### Command Flags + +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) + +## Directory Structure + +``` +openspec/ +├── project.md # Project conventions +├── specs/ # Current truth - what IS built +│ └── [capability]/ # Single focused capability +│ ├── spec.md # Requirements and scenarios +│ └── design.md # Technical patterns +├── changes/ # Proposals - what SHOULD change +│ ├── [change-name]/ +│ │ ├── proposal.md # Why, what, impact +│ │ ├── tasks.md # Implementation checklist +│ │ ├── design.md # Technical decisions (optional; see criteria) +│ │ └── specs/ # Delta changes +│ │ └── [capability]/ +│ │ └── spec.md # ADDED/MODIFIED/REMOVED +│ └── archive/ # Completed changes +``` + +## Creating Change Proposals + +### Decision Tree + +``` +New request? +├─ Bug fix restoring spec behavior? → Fix directly +├─ Typo/format/comment? → Fix directly +├─ New feature/capability? → Create proposal +├─ Breaking change? → Create proposal +├─ Architecture change? → Create proposal +└─ Unclear? → Create proposal (safer) +``` + +### Proposal Structure + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] + +## Why +[1-2 sentences on problem/opportunity] + +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... + +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] + +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. + +4. **Create tasks.md:** +```markdown +## 1. Implementation +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` + +5. **Create design.md when needed:** +Create `design.md` if any of the following apply; otherwise omit it: +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding + +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] + +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] + +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs +- [Risk] → Mitigation + +## Migration Plan +[Steps, rollback] + +## Open Questions +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### When to use ADDED vs MODIFIED +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. + +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. + +Authoring a MODIFIED requirement correctly: +1) Locate the existing requirement in `openspec/specs//spec.md`. +2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict --no-interactive + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict --no-interactive +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` + +## Best Practices + +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns + +### Complexity Triggers +Only add complexity with: +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction + +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs + +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" + +### Change ID Naming +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. + +## Tool Selection Guide + +| Task | Tool | Why | +|------|------|-----| +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | + +## Error Recovery + +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals + +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## Quick Reference + +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes + +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior + +### CLI Essentials +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec validate --strict --no-interactive # Is it correct? +openspec archive [--yes|-y] # Mark complete (add --yes for automation) +``` + +Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/changes/add-ical-subscription-export/design.md b/openspec/changes/add-ical-subscription-export/design.md new file mode 100644 index 0000000..2ba78b5 --- /dev/null +++ b/openspec/changes/add-ical-subscription-export/design.md @@ -0,0 +1,320 @@ +# Design: iCal Subscription Export + +## Context + +La Suite Calendars uses SabreDAV as its CalDAV server, but the current +authentication model (API key + X-Forwarded-User headers) prevents direct +access from external calendar clients. Users need a way to subscribe to their +calendars from applications like Apple Calendar, Google Calendar, etc. + +SabreDAV provides an `ICSExportPlugin` that generates RFC 5545 compliant iCal +files. We want to leverage this plugin while providing a clean, unauthenticated +URL for external calendar applications. + +## Goals / Non-Goals + +**Goals:** +- Allow users to subscribe to their calendars from external applications +- Per-calendar subscription URLs with private tokens +- Clean URL format similar to Google Calendar / Outlook +- Ability to revoke/regenerate tokens +- Reuse SabreDAV's ICSExportPlugin for ICS generation +- **Standalone tokens that don't require synchronizing CalDAV calendars with Django** + +**Non-Goals:** +- Write access from external clients (read-only subscriptions) +- Full CalDAV protocol support for external clients +- Importing external calendars into La Suite Calendars (future feature) +- Real-time sync (clients poll at their own refresh rate) + +## Decisions + +### 1. URL Format + +**Decision:** Use a short, clean URL with token in the path: + +``` +https:///ical/.ics +``` + +**Examples from other services:** +- Google: `https://calendar.google.com/calendar/ical//public/basic.ics` +- Outlook: `https://outlook.office365.com/owa/calendar///calendar.ics` + +**Rationale:** +- Industry standard format +- No authentication prompt in calendar apps (token IS the auth) +- Easy to copy/paste +- Token not exposed in query strings (cleaner logs) + +### 2. Django Proxy to SabreDAV + +**Decision:** Django handles the public endpoint and proxies to SabreDAV. + +``` +Apple Calendar + │ + │ GET /ical/.ics (no auth headers) + ▼ + Django (public endpoint) + │ + │ 1. Extract token from URL + │ 2. Lookup CalendarSubscriptionToken in DB + │ 3. Get caldav_path and owner.email directly from token + ▼ + Django → SabreDAV (internal) + │ + │ GET /calendars//?export + │ Headers: X-Api-Key, X-Forwarded-User + ▼ + SabreDAV ICSExportPlugin + │ + │ Generates RFC 5545 ICS + ▼ + Django returns ICS to client +``` + +**Rationale:** +- No changes to SabreDAV authentication backend +- Clean separation: Django handles tokens, SabreDAV handles CalDAV +- Token validation logic stays in Python (easier to test/maintain) +- Reuses existing CalDAV proxy infrastructure + +### 3. Token Storage - Standalone Model + +**Decision:** Django model `CalendarSubscriptionToken` is **standalone** and stores the CalDAV path directly: + +```python +class CalendarSubscriptionToken(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + # Owner of the calendar (for permission verification) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="subscription_tokens", + ) + + # CalDAV path stored directly (e.g., /calendars/user@example.com/uuid/) + caldav_path = models.CharField(max_length=512) + + # Calendar display name (for UI and filename) + calendar_name = models.CharField(max_length=255, blank=True, default="") + + token = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) + is_active = models.BooleanField(default=True) + last_accessed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["owner", "caldav_path"], + name="unique_token_per_owner_calendar", + ) + ] +``` + +**Rationale:** +- **No dependency on Django Calendar model** - tokens work directly with CalDAV paths +- No need to synchronize CalDAV calendars to Django before creating tokens +- Works for all calendars (not just those previously synced to Django) +- Avoids fragile name-matching when multiple calendars have the same name +- UUID provides 128 bits of entropy (secure) +- `is_active` allows soft-disable without deletion +- `last_accessed_at` for auditing +- Unique constraint ensures one token per user+calendar combination + +### 4. Token Scope: Per Calendar Path + +**Decision:** One token per user + CalDAV path combination. + +**Rationale:** +- Users can share specific calendars without exposing all calendars +- Revoking one calendar's access doesn't affect others +- Permission verification via path: user's email must be in the CalDAV path + +### 5. Permission Verification via CalDAV Path + +**Decision:** Verify ownership by checking the user's email is in the CalDAV path. + +```python +def _verify_caldav_access(self, user, caldav_path): + # Path format: /calendars/user@example.com/uuid/ + parts = caldav_path.strip("/").split("/") + if len(parts) >= 2 and parts[0] == "calendars": + path_email = unquote(parts[1]) + return path_email.lower() == user.email.lower() + return False +``` + +**Rationale:** +- CalDAV paths inherently contain the owner's email +- No need to query CalDAV server to check permissions +- Simple and fast verification + +### 6. ICS Generation via SabreDAV + +**Decision:** Use SabreDAV's `ICSExportPlugin` instead of generating ICS in +Django. + +**Rationale:** +- ICSExportPlugin is battle-tested and RFC 5545 compliant +- Handles recurring events, timezones, and edge cases correctly +- No code duplication +- SabreDAV already has the calendar data + +**Required change in `server.php`:** +```php +$server->addPlugin(new CalDAV\ICSExportPlugin()); +``` + +## API Design + +### Public Endpoint (no authentication) + +``` +GET /ical/.ics + → Validates token + → Proxies to SabreDAV using token.caldav_path and token.owner.email + → Returns ICS (Content-Type: text/calendar) + → 404 if token invalid/inactive +``` + +### Token Management (authenticated Django API) + +**New standalone endpoint:** + +``` +POST /api/v1.0/subscription-tokens/ + Body: { caldav_path, calendar_name (optional) } + → Creates token or returns existing (owner only) + → Verifies user's email is in caldav_path + → Returns: { token, url, caldav_path, calendar_name, created_at } + +GET /api/v1.0/subscription-tokens/by-path/?caldav_path=... + → Returns existing token or 404 + +DELETE /api/v1.0/subscription-tokens/by-path/?caldav_path=... + → Deletes token (revokes access) +``` + +### Frontend Flow + +1. User clicks "Get subscription URL" on a calendar +2. Frontend extracts CalDAV path from the calendar's URL +3. Frontend calls `POST /subscription-tokens/` with `{ caldav_path, calendar_name }` +4. Backend creates token (or returns existing) and returns subscription URL +5. Modal displays URL with copy button + +## Security Considerations + +### Token as Secret + +- Token is a UUID (128 bits of entropy) - infeasible to brute force +- Knowledge of token = read access to calendar +- URL should be treated as confidential + +### Mitigations + +- Clear UI warning about URL privacy +- Easy token regeneration (delete + create) +- `last_accessed_at` tracking for auditing +- Rate limiting on `/ical/` endpoint (future) + +### Attack Surface + +- Token in URL may appear in: + - Server access logs → configure log rotation, mask tokens + - Browser history (if opened in browser) → minor concern + - Referrer headers → set `Referrer-Policy: no-referrer` +- No CSRF risk (read-only, no state changes via GET) + +## Implementation Notes + +### Django View for /ical/.ics + +```python +class ICalExportView(View): + def get(self, request, token): + # 1. Lookup token + subscription = CalendarSubscriptionToken.objects.filter( + token=token, is_active=True + ).select_related('owner').first() + + if not subscription: + raise Http404 + + # 2. Update last_accessed_at + subscription.last_accessed_at = timezone.now() + subscription.save(update_fields=['last_accessed_at']) + + # 3. Proxy to SabreDAV using caldav_path and owner directly + caldav_path = subscription.caldav_path.lstrip("/") + caldav_url = f"{settings.CALDAV_URL}/api/v1.0/caldav/{caldav_path}?export" + + response = requests.get( + caldav_url, + headers={ + 'X-Api-Key': settings.CALDAV_OUTBOUND_API_KEY, + 'X-Forwarded-User': subscription.owner.email, + } + ) + + # 4. Return ICS + display_name = subscription.calendar_name or "calendar" + return HttpResponse( + response.content, + content_type='text/calendar', + headers={ + 'Content-Disposition': f'attachment; filename="{display_name}.ics"', + 'Cache-Control': 'no-store, private', + 'Referrer-Policy': 'no-referrer', + } + ) +``` + +### URL Configuration + +```python +# urls.py +urlpatterns = [ + path('ical/.ics', ICalExportView.as_view(), name='ical-export'), +] +``` + +## Risks / Trade-offs + +### Trade-off: Extra HTTP Hop + +Django proxies to SabreDAV (local network call). +- **Pro:** Clean architecture, no PHP changes +- **Con:** Slight latency (~1-5ms on localhost) +- **Verdict:** Acceptable for a polling use case (clients refresh hourly) + +### Risk: Token Leakage + +If URL is shared/leaked, anyone can read the calendar. +- **Mitigation:** Regenerate token feature, access logging, UI warnings + +### Risk: Large Calendar Performance + +Generating ICS for calendars with thousands of events. +- **Mitigation:** SabreDAV handles this efficiently +- **Future:** Add date range filtering (`?start=...&end=...`) + +## Migration Plan + +1. Add `CalendarSubscriptionToken` Django model with standalone fields +2. Create migration (adds owner, caldav_path, calendar_name fields) +3. Add `ICSExportPlugin` to SabreDAV `server.php` +4. Create Django `/ical/.ics` endpoint +5. Add standalone `SubscriptionTokenViewSet` API +6. Update frontend to use caldav_path instead of calendar ID +7. No data migration needed (new feature) + +## References + +- [SabreDAV ICSExportPlugin](https://sabre.io/dav/ics-export-plugin/) +- [Google Calendar public URL format](https://support.google.com/calendar/answer/37083) +- [Outlook calendar publishing](https://support.microsoft.com/en-us/office/introduction-to-publishing-internet-calendars-a25e68d6-695a-41c6-a701-103d44ba151d) diff --git a/openspec/changes/add-ical-subscription-export/proposal.md b/openspec/changes/add-ical-subscription-export/proposal.md new file mode 100644 index 0000000..0a47ab2 --- /dev/null +++ b/openspec/changes/add-ical-subscription-export/proposal.md @@ -0,0 +1,62 @@ +# Change: Add iCal Subscription Export + +## Why + +Users want to subscribe to their La Suite Calendars calendars from external +applications (Apple Calendar, Google Calendar, Thunderbird, Outlook, etc.) +using standard iCal URLs. Currently, external access is not supported due to +the API-key authentication model, preventing users from accessing their +calendars outside the web application. + +## What Changes + +- Add a standalone `CalendarSubscriptionToken` Django model that stores: + - Owner (user) reference + - CalDAV path directly (no FK to Calendar model) + - Calendar display name + - Token UUID and metadata +- Create a public Django endpoint `/ical/.ics` that: + - Validates the token (no user authentication required) + - Proxies the request to SabreDAV using the stored caldav_path and owner email + - Returns the ICS generated by SabreDAV's `ICSExportPlugin` +- Enable SabreDAV's `ICSExportPlugin` in `server.php` +- Add standalone Django REST API endpoint `/api/v1.0/subscription-tokens/`: + - POST to create token with `{ caldav_path, calendar_name }` + - GET/DELETE by-path to manage existing tokens + - Permission verification via caldav_path (user's email must be in path) +- Add UI in the calendar context menu to obtain and copy the subscription URL + - Frontend extracts CalDAV path from calendar URL + - Modal auto-creates token on open + +## Architecture Approach + +**URL format:** `https://calendars.example.com/ical/.ics` + +**Key design decision:** The token model is **standalone** and stores the CalDAV +path directly, avoiding the need to synchronize CalDAV calendars with Django. + +This follows the same pattern as Google Calendar and Outlook: +- Short, clean URL +- Token in URL path acts as authentication (no username/password) +- Reuses SabreDAV's ICSExportPlugin for RFC 5545 compliant ICS generation + +See `design.md` for detailed technical flow. + +## Impact + +- **Affected specs**: New capability `ical-subscription-export` +- **Affected code**: + - `docker/sabredav/server.php` - Add ICSExportPlugin + - `src/backend/core/models.py` - New CalendarSubscriptionToken model (standalone) + - `src/backend/core/api/viewsets.py` - New SubscriptionTokenViewSet + - `src/backend/core/api/viewsets_ical.py` - New ICalExportView + - `src/frontend/apps/calendars/` - UI for subscription URL with caldavPath +- **Security**: Tokens are random UUIDs; URLs should be treated as secrets +- **Database**: New Django table for subscription tokens +- **No breaking changes** to existing functionality + +## Out of Scope (Future Work) + +- Subscribing TO external calendars from within La Suite Calendars (import) +- CalDAV access with HTTP Basic authentication +- Public (unauthenticated) calendar sharing without token diff --git a/openspec/changes/add-ical-subscription-export/specs/ical-subscription-export/spec.md b/openspec/changes/add-ical-subscription-export/specs/ical-subscription-export/spec.md new file mode 100644 index 0000000..06295ac --- /dev/null +++ b/openspec/changes/add-ical-subscription-export/specs/ical-subscription-export/spec.md @@ -0,0 +1,176 @@ +## ADDED Requirements + +### Requirement: Calendar Subscription Token Management + +The system SHALL allow calendar owners to generate a private subscription token +for their calendars using CalDAV paths directly, enabling read-only access via +iCal URL from external calendar applications without requiring Django Calendar +model synchronization. + +#### Scenario: Owner generates subscription token + +- **GIVEN** a user owns a calendar with CalDAV path `/calendars///` +- **WHEN** the user requests a subscription token with that CalDAV path +- **THEN** the system verifies the user's email matches the path +- **AND** generates a unique UUID token stored with the CalDAV path +- **AND** returns the subscription URL in format `/ical/.ics` + +#### Scenario: Owner retrieves existing subscription token + +- **GIVEN** a user owns a calendar with an existing subscription token +- **WHEN** the user requests the subscription token by CalDAV path +- **THEN** the system returns the existing token and URL + +#### Scenario: Owner regenerates subscription token + +- **GIVEN** a user owns a calendar with an existing subscription token +- **WHEN** the user deletes the token and creates a new one +- **THEN** the old token is invalidated +- **AND** a new unique token is generated +- **AND** the old subscription URL no longer works + +#### Scenario: Owner revokes subscription token + +- **GIVEN** a user owns a calendar with an existing subscription token +- **WHEN** the user requests to delete the token by CalDAV path +- **THEN** the system removes the token +- **AND** the subscription URL returns 404 + +#### Scenario: Non-owner cannot manage subscription token + +- **GIVEN** a user attempts to create a token for a CalDAV path not containing their email +- **WHEN** the user sends a request with that CalDAV path +- **THEN** the system rejects the request with a 403 permission error + +#### Scenario: One token per calendar path per owner + +- **GIVEN** a user already has a subscription token for a CalDAV path +- **WHEN** the user requests to create another token for the same path +- **THEN** the system returns the existing token instead of creating a duplicate + +--- + +### Requirement: Public iCal Export Endpoint + +The system SHALL provide a public endpoint that serves calendar data in iCal +format when accessed with a valid subscription token, without requiring user +authentication, using the CalDAV path stored directly in the token. + +#### Scenario: Valid token returns calendar data + +- **GIVEN** a valid and active subscription token exists with a CalDAV path +- **WHEN** an HTTP GET request is made to `/ical/.ics` +- **THEN** the system proxies to SabreDAV using the token's caldav_path and owner email +- **AND** returns the calendar events in iCal format +- **AND** the response Content-Type is `text/calendar` +- **AND** the response is RFC 5545 compliant +- **AND** no authentication headers are required + +#### Scenario: Invalid token returns 404 + +- **GIVEN** a token that does not exist in the system +- **WHEN** an HTTP GET request is made to `/ical/.ics` +- **THEN** the system returns HTTP 404 Not Found + +#### Scenario: Deleted token returns 404 + +- **GIVEN** a subscription token that has been deleted +- **WHEN** an HTTP GET request is made to `/ical/.ics` +- **THEN** the system returns HTTP 404 Not Found + +#### Scenario: Access tracking + +- **GIVEN** a valid subscription token +- **WHEN** the iCal endpoint is accessed successfully +- **THEN** the system updates the token's last accessed timestamp + +#### Scenario: Security headers are set + +- **GIVEN** a valid subscription URL +- **WHEN** the iCal endpoint returns a response +- **THEN** the response includes `Cache-Control: no-store, private` +- **AND** the response includes `Referrer-Policy: no-referrer` + +#### Scenario: Compatible with external calendar apps + +- **GIVEN** a valid subscription URL +- **WHEN** the URL is added to Apple Calendar as a subscription +- **THEN** Apple Calendar successfully subscribes and displays events +- **AND** events sync automatically on refresh + +--- + +### Requirement: Subscription URL User Interface + +The system SHALL provide a user interface for calendar owners to obtain and +manage subscription URLs using CalDAV paths extracted from calendar URLs. + +#### Scenario: Access subscription URL from calendar menu + +- **GIVEN** a user is viewing their calendars +- **WHEN** the user opens the context menu for a calendar they own +- **THEN** an option to get the subscription URL is available + +#### Scenario: Subscription option hidden for non-owned calendars + +- **GIVEN** a user has shared access to a calendar but is not the owner +- **WHEN** the user opens the context menu for that calendar +- **THEN** the subscription URL option is NOT displayed + +#### Scenario: Display subscription URL modal + +- **GIVEN** a user clicks the subscription URL option for their calendar +- **WHEN** the modal opens +- **THEN** the frontend extracts the CalDAV path from the calendar URL +- **AND** creates or retrieves the token using the CalDAV path +- **AND** the full subscription URL is displayed +- **AND** a "Copy to clipboard" button is available +- **AND** a warning about keeping the URL private is shown +- **AND** an option to regenerate the URL is available + +#### Scenario: Copy URL to clipboard + +- **GIVEN** the subscription URL modal is open +- **WHEN** the user clicks "Copy to clipboard" +- **THEN** the URL is copied to the system clipboard +- **AND** visual feedback confirms the copy was successful + +#### Scenario: Regenerate token from modal + +- **GIVEN** the subscription URL modal is open +- **WHEN** the user clicks to regenerate the URL +- **THEN** a confirmation dialog is shown +- **AND** upon confirmation, the old token is deleted +- **AND** a new token is generated +- **AND** the modal updates to show the new URL + +#### Scenario: Error handling in modal + +- **GIVEN** the subscription URL modal is open +- **WHEN** the initial token fetch returns 404 (no existing token) +- **THEN** the system automatically creates a new token +- **AND** no error message is displayed to the user +- **BUT** if token creation fails, an error message is displayed + +--- + +### Requirement: Standalone Token Storage + +The system SHALL store subscription tokens independently of the Django Calendar +model, using CalDAV paths directly to enable token management without requiring +CalDAV-to-Django synchronization. + +#### Scenario: Token stores CalDAV path directly + +- **GIVEN** a subscription token is created +- **THEN** the token record includes the full CalDAV path +- **AND** the token record includes the owner (user) reference +- **AND** the token record includes an optional calendar display name +- **AND** no foreign key to Django Calendar model is required + +#### Scenario: Permission verification via path + +- **GIVEN** a user requests a subscription token +- **WHEN** the system verifies permissions +- **THEN** it checks that the user's email appears in the CalDAV path +- **AND** does not require querying the CalDAV server diff --git a/openspec/changes/add-ical-subscription-export/tasks.md b/openspec/changes/add-ical-subscription-export/tasks.md new file mode 100644 index 0000000..a2c3aa1 --- /dev/null +++ b/openspec/changes/add-ical-subscription-export/tasks.md @@ -0,0 +1,127 @@ +# Tasks: iCal Subscription Export + +## 1. Backend - Data Model + +- [x] 1.1 Create `CalendarSubscriptionToken` model in `core/models.py` + - ForeignKey to User (owner, on_delete=CASCADE) + - `caldav_path` field (CharField, max_length=512) - stores CalDAV path directly + - `calendar_name` field (CharField, max_length=255, optional) - for display + - `token` field (UUID, unique, indexed, default=uuid4) + - `is_active` boolean (default=True) + - `last_accessed_at` DateTimeField (nullable) + - UniqueConstraint on (owner, caldav_path) +- [x] 1.2 Create and run database migration +- [x] 1.3 Add model to Django admin for debugging + +## 2. SabreDAV - Enable ICSExportPlugin + +- [x] 2.1 Add `ICSExportPlugin` to `server.php`: + ```php + $server->addPlugin(new CalDAV\ICSExportPlugin()); + ``` +- [x] 2.2 Test that `?export` works via existing CalDAV proxy + +## 3. Backend - Public iCal Endpoint + +- [x] 3.1 Create `ICalExportView` in `core/api/viewsets_ical.py` + - No authentication required (public endpoint) + - Extract token from URL path + - Lookup `CalendarSubscriptionToken` by token + - Return 404 if token invalid/inactive + - Update `last_accessed_at` on access + - Proxy request to SabreDAV using `token.caldav_path` and `token.owner.email` + - Return ICS response with `Content-Type: text/calendar` + - Set security headers (Cache-Control, Referrer-Policy) +- [x] 3.2 Add URL route: `path('ical/.ics', ...)` +- [x] 3.3 Write tests for public endpoint (valid token, invalid token, inactive) + +## 4. Backend - Standalone Token Management API + +- [x] 4.1 Create serializers in `core/api/serializers.py` + - `CalendarSubscriptionTokenSerializer` - fields: token, url, caldav_path, calendar_name, etc. + - `CalendarSubscriptionTokenCreateSerializer` - for POST body validation +- [x] 4.2 Create standalone `SubscriptionTokenViewSet` in `core/api/viewsets.py`: + - `POST /subscription-tokens/` - create token with { caldav_path, calendar_name } + - `GET /subscription-tokens/by-path/?caldav_path=...` - get existing token + - `DELETE /subscription-tokens/by-path/?caldav_path=...` - revoke token + - Permission verification: user's email must be in caldav_path +- [x] 4.3 Register viewset in `core/urls.py` +- [x] 4.4 Write API tests for token management (create, get, delete, permissions) + +## 5. Frontend - API Integration + +- [x] 5.1 Add API functions in `features/calendar/api.ts`: + - `getSubscriptionToken(caldavPath)` - GET by-path + - `createSubscriptionToken({ caldavPath, calendarName })` - POST + - `deleteSubscriptionToken(caldavPath)` - DELETE by-path +- [x] 5.2 Update React Query hooks in `hooks/useCalendars.ts` + - Use caldavPath instead of calendarId + +## 6. Frontend - UI Components + +- [x] 6.1 Update `SubscriptionUrlModal` component + - Accept `caldavPath` prop instead of `calendarId` + - Extract caldavPath from calendar URL in parent component + - Display the subscription URL in a copyable field + - "Copy to clipboard" button with success feedback + - Warning text about URL being private + - "Regenerate URL" button with confirmation dialog + - Only show error alert for real errors (not for expected 404) +- [x] 6.2 Update `CalendarList.tsx` + - Extract CalDAV path from calendar URL + - Pass caldavPath to SubscriptionUrlModal +- [x] 6.3 Add translations (i18n) for new UI strings + +## 7. Cleanup + +- [x] 7.1 Remove old `subscription_token` action from CalendarViewSet +- [x] 7.2 Remove `sync-from-caldav` endpoint (no longer needed) +- [x] 7.3 Remove `syncFromCaldav` from frontend API + +## 8. Testing & Validation + +- [x] 8.1 Manual test: add URL to Apple Calendar +- [ ] 8.2 Manual test: add URL to Google Calendar +- [x] 8.3 Verify token regeneration invalidates old URL +- [ ] 8.4 E2E test for subscription workflow (optional) + +## Dependencies + +``` +1 (Django model) + ↓ +2 (ICSExportPlugin) ──────┐ + ↓ │ +3 (Public endpoint) ──────┤ can run in parallel after 1 + ↓ │ +4 (Token API) ────────────┘ + ↓ +5 (Frontend API) + ↓ +6 (Frontend UI) + ↓ +7 (Cleanup) + ↓ +8 (Testing) +``` + +## Key Files Modified + +### Backend +- `src/backend/core/models.py` - CalendarSubscriptionToken model (standalone) +- `src/backend/core/migrations/0002_calendarsubscriptiontoken.py` +- `src/backend/core/api/serializers.py` - Token serializers +- `src/backend/core/api/viewsets.py` - SubscriptionTokenViewSet +- `src/backend/core/api/viewsets_ical.py` - ICalExportView +- `src/backend/core/urls.py` - Route registration +- `src/backend/core/admin.py` - Admin configuration +- `src/backend/core/factories.py` - Test factory + +### Frontend +- `src/features/calendar/api.ts` - API functions with caldavPath +- `src/features/calendar/hooks/useCalendars.ts` - React Query hooks +- `src/features/calendar/components/calendar-list/CalendarList.tsx` +- `src/features/calendar/components/calendar-list/SubscriptionUrlModal.tsx` + +### SabreDAV +- `docker/sabredav/server.php` - ICSExportPlugin enabled diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 0000000..2408a53 --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,126 @@ +## Project Overview + +La Suite Calendars is a modern calendar application for managing events and schedules. It's a full-stack application with: +- **Backend**: Django 5 REST API with PostgreSQL +- **Frontend**: Next.js 15 with React 19 +- **CalDAV Server**: SabreDAV (PHP-based) for calendar protocol support : https://sabre.io/dav/ +- **Authentication**: Keycloak OIDC provider + +In this project, you can create events, invite people to events, create calendars, and invite others to share and manage those calendars, allowing them to add and manage events as well. Every invitation sends an email with an ICS file attached; this also happens for event updates and cancellations. + +## Common Commands + +### Development Setup +```bash +make bootstrap # Initial setup: builds containers, runs migrations, starts services +make run # Start all services (backend + frontend containers) +make run-backend # Start backend services only (for local frontend development) +make stop # Stop all containers +make down # Stop and remove containers, networks, volumes +``` + +### Backend Development +```bash +make test-back -- path/to/test.py::TestClass::test_method # Run specific test +make test-back-parallel # Run all tests in parallel +make lint # Run ruff + pylint +make migrate # Run Django migrations +make makemigrations # Create new migrations +make shell # Django shell +make dbshell # PostgreSQL shell +``` + +### Frontend Development +```bash +make frontend-development-install # Install frontend dependencies locally +make run-frontend-development # Run frontend locally (after run-backend) +make frontend-lint # Run ESLint on frontend +cd src/frontend/apps/calendars && yarn test # Run frontend tests +cd src/frontend/apps/calendars && yarn test:watch # Watch mode +``` + +### E2E Tests +```bash +make run-tests-e2e # Run all e2e tests +make run-tests-e2e -- --project chromium --headed # Run with specific browser +``` + +### Internationalization +```bash +make i18n-generate # Generate translation files +make i18n-compile # Compile translations +make crowdin-upload # Upload sources to Crowdin +make crowdin-download # Download translations from Crowdin +``` + +## Architecture + +### Backend Structure (`src/backend/`) +- `calendars/` - Django project configuration, settings, Celery app +- `core/` - Main application code: + - `api/` - DRF viewsets and serializers + - `models.py` - Database models + - `services/` - Business logic + - `authentication/` - OIDC authentication + - `tests/` - pytest test files + +### Frontend Structure (`src/frontend/`) +Yarn workspaces monorepo: +- `apps/calendars/` - Main Next.js application + - `src/features/` - Feature modules (calendar, auth, api, i18n, etc.) + - `src/pages/` - Next.js pages + - `src/hooks/` - Custom React hooks +- `apps/e2e/` - Playwright end-to-end tests + +### CalDAV Server (`docker/sabredav/`) +PHP SabreDAV server providing CalDAV protocol support, running against the shared PostgreSQL database. + +### Service Ports (Development) +- Frontend: http://localhost:8920 +- Backend API: http://localhost:8921 +- CalDAV: http://localhost:8922 +- Keycloak: http://localhost:8925 +- PostgreSQL: 8912 +- Mailcatcher: http://localhost:1081 + +## Key Technologies + +### Backend +- Django 5 with Django REST Framework +- Celery with Redis for background tasks +- pytest for testing (use `bin/pytest` wrapper) +- Ruff for linting/formatting (100 char line length for pylint compatibility) + +### Frontend +- Next.js 15 with React 19 +- @tanstack/react-query for data fetching +- tsdav/ical.js/tsics for CalDAV client integration : https://tsdav.vercel.app/docs/intro / https://github.com/Neuvernetzung/ts-ics +- @gouvfr-lasuite/cunningham-react for UI components : https://github.com/suitenumerique/cunningham +- Jest for unit tests +- Playwright for e2e tests + +## Code Style + +### Python +- Follow PEP 8 with 100 character line limit +- Use Django REST Framework viewsets for APIs +- Business logic in models and services, keep views thin +- Use `select_related`/`prefetch_related` for query optimization + +### TypeScript/React +- Feature-based folder structure under `src/features/` +- Use React Query for server state management as possible, if it is not possible, don't worry. +- Use the vercel-react-best-practices skill when you write a react code +- Please, make many tiny files and separate components in differentes files +- Check for Lint and TypeScript errors before telling me that you have finished + +### Git + +- Maximum line length: 80 characters. +- Each commit must have a title and a description. +- The commit title should start with a Gitmoji, then the area in parentheses + (e.g. back, front, docs), then your chosen title. + +# Workflow +- Be sure to typecheck when you're done making a series of code changes +- Prefer running single tests, and not the whole test suite, for performance \ No newline at end of file