From 41f6ae916fd03f2fd2ce10ba77a1b55b9da04c8f Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 28 Feb 2026 02:17:55 +0000 Subject: [PATCH] first commit Made-with: Cursor --- .env.example | 12 + .gitignore | 48 + README.md | 279 ++++++ SKILL.md | 396 ++++++++ about/details.md | 899 +++++++++++++++++++ about/logic.md | 501 +++++++++++ about/overview.md | 389 ++++++++ cmd/server/main.go | 139 +++ go.mod | 31 + go.sum | 123 +++ internal/api/handlers/apikeys.go | 68 ++ internal/api/handlers/attendees.go | 106 +++ internal/api/handlers/auth.go | 109 +++ internal/api/handlers/availability.go | 60 ++ internal/api/handlers/booking.go | 98 ++ internal/api/handlers/calendars.go | 112 +++ internal/api/handlers/contacts.go | 146 +++ internal/api/handlers/events.go | 204 +++++ internal/api/handlers/ics.go | 189 ++++ internal/api/handlers/reminders.go | 64 ++ internal/api/handlers/sharing.go | 84 ++ internal/api/handlers/users.go | 73 ++ internal/api/openapi/openapi.go | 120 +++ internal/api/openapi/specs/apikeys.json | 101 +++ internal/api/openapi/specs/auth.json | 183 ++++ internal/api/openapi/specs/availability.json | 42 + internal/api/openapi/specs/base.json | 41 + internal/api/openapi/specs/booking.json | 191 ++++ internal/api/openapi/specs/calendars.json | 291 ++++++ internal/api/openapi/specs/contacts.json | 189 ++++ internal/api/openapi/specs/events.json | 438 +++++++++ internal/api/openapi/specs/ics.json | 82 ++ internal/api/openapi/specs/schemas.json | 181 ++++ internal/api/openapi/specs/users.json | 76 ++ internal/api/routes.go | 128 +++ internal/auth/jwt.go | 67 ++ internal/config/config.go | 59 ++ internal/middleware/auth.go | 102 +++ internal/middleware/context.go | 64 ++ internal/middleware/ratelimit.go | 86 ++ internal/models/errors.go | 52 ++ internal/models/models.go | 164 ++++ internal/repository/api_keys.sql.go | 134 +++ internal/repository/attachments.sql.go | 72 ++ internal/repository/attendees.sql.go | 135 +++ internal/repository/audit_logs.sql.go | 34 + internal/repository/booking_links.sql.go | 148 +++ internal/repository/calendar_members.sql.go | 105 +++ internal/repository/calendars.sql.go | 209 +++++ internal/repository/contacts.sql.go | 228 +++++ internal/repository/db.go | 32 + internal/repository/event_exceptions.sql.go | 74 ++ internal/repository/events.sql.go | 479 ++++++++++ internal/repository/models.go | 139 +++ internal/repository/refresh_tokens.sql.go | 83 ++ internal/repository/reminders.sql.go | 83 ++ internal/repository/users.sql.go | 165 ++++ internal/scheduler/scheduler.go | 63 ++ internal/scheduler/worker.go | 67 ++ internal/service/apikey.go | 84 ++ internal/service/attendee.go | 132 +++ internal/service/audit.go | 30 + internal/service/auth.go | 256 ++++++ internal/service/availability.go | 86 ++ internal/service/booking.go | 264 ++++++ internal/service/calendar.go | 318 +++++++ internal/service/contact.go | 172 ++++ internal/service/event.go | 583 ++++++++++++ internal/service/reminder.go | 132 +++ internal/service/user.go | 92 ++ internal/utils/pagination.go | 59 ++ internal/utils/pgtype.go | 88 ++ internal/utils/response.go | 44 + internal/utils/validation.go | 97 ++ llms.txt | 129 +++ migrations/000001_init.down.sql | 13 + migrations/000001_init.up.sql | 174 ++++ sqlc/queries/api_keys.sql | 23 + sqlc/queries/attachments.sql | 13 + sqlc/queries/attendees.sql | 25 + sqlc/queries/audit_logs.sql | 3 + sqlc/queries/booking_links.sql | 23 + sqlc/queries/calendar_members.sql | 23 + sqlc/queries/calendars.sql | 33 + sqlc/queries/contacts.sql | 46 + sqlc/queries/event_exceptions.sql | 10 + sqlc/queries/events.sql | 98 ++ sqlc/queries/refresh_tokens.sql | 16 + sqlc/queries/reminders.sql | 18 + sqlc/queries/users.sql | 25 + sqlc/schema.sql | 174 ++++ sqlc/sqlc.yaml | 12 + 92 files changed, 12332 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 about/details.md create mode 100644 about/logic.md create mode 100644 about/overview.md create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/handlers/apikeys.go create mode 100644 internal/api/handlers/attendees.go create mode 100644 internal/api/handlers/auth.go create mode 100644 internal/api/handlers/availability.go create mode 100644 internal/api/handlers/booking.go create mode 100644 internal/api/handlers/calendars.go create mode 100644 internal/api/handlers/contacts.go create mode 100644 internal/api/handlers/events.go create mode 100644 internal/api/handlers/ics.go create mode 100644 internal/api/handlers/reminders.go create mode 100644 internal/api/handlers/sharing.go create mode 100644 internal/api/handlers/users.go create mode 100644 internal/api/openapi/openapi.go create mode 100644 internal/api/openapi/specs/apikeys.json create mode 100644 internal/api/openapi/specs/auth.json create mode 100644 internal/api/openapi/specs/availability.json create mode 100644 internal/api/openapi/specs/base.json create mode 100644 internal/api/openapi/specs/booking.json create mode 100644 internal/api/openapi/specs/calendars.json create mode 100644 internal/api/openapi/specs/contacts.json create mode 100644 internal/api/openapi/specs/events.json create mode 100644 internal/api/openapi/specs/ics.json create mode 100644 internal/api/openapi/specs/schemas.json create mode 100644 internal/api/openapi/specs/users.json create mode 100644 internal/api/routes.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/config/config.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/context.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/models/errors.go create mode 100644 internal/models/models.go create mode 100644 internal/repository/api_keys.sql.go create mode 100644 internal/repository/attachments.sql.go create mode 100644 internal/repository/attendees.sql.go create mode 100644 internal/repository/audit_logs.sql.go create mode 100644 internal/repository/booking_links.sql.go create mode 100644 internal/repository/calendar_members.sql.go create mode 100644 internal/repository/calendars.sql.go create mode 100644 internal/repository/contacts.sql.go create mode 100644 internal/repository/db.go create mode 100644 internal/repository/event_exceptions.sql.go create mode 100644 internal/repository/events.sql.go create mode 100644 internal/repository/models.go create mode 100644 internal/repository/refresh_tokens.sql.go create mode 100644 internal/repository/reminders.sql.go create mode 100644 internal/repository/users.sql.go create mode 100644 internal/scheduler/scheduler.go create mode 100644 internal/scheduler/worker.go create mode 100644 internal/service/apikey.go create mode 100644 internal/service/attendee.go create mode 100644 internal/service/audit.go create mode 100644 internal/service/auth.go create mode 100644 internal/service/availability.go create mode 100644 internal/service/booking.go create mode 100644 internal/service/calendar.go create mode 100644 internal/service/contact.go create mode 100644 internal/service/event.go create mode 100644 internal/service/reminder.go create mode 100644 internal/service/user.go create mode 100644 internal/utils/pagination.go create mode 100644 internal/utils/pgtype.go create mode 100644 internal/utils/response.go create mode 100644 internal/utils/validation.go create mode 100644 llms.txt create mode 100644 migrations/000001_init.down.sql create mode 100644 migrations/000001_init.up.sql create mode 100644 sqlc/queries/api_keys.sql create mode 100644 sqlc/queries/attachments.sql create mode 100644 sqlc/queries/attendees.sql create mode 100644 sqlc/queries/audit_logs.sql create mode 100644 sqlc/queries/booking_links.sql create mode 100644 sqlc/queries/calendar_members.sql create mode 100644 sqlc/queries/calendars.sql create mode 100644 sqlc/queries/contacts.sql create mode 100644 sqlc/queries/event_exceptions.sql create mode 100644 sqlc/queries/events.sql create mode 100644 sqlc/queries/refresh_tokens.sql create mode 100644 sqlc/queries/reminders.sql create mode 100644 sqlc/queries/users.sql create mode 100644 sqlc/schema.sql create mode 100644 sqlc/sqlc.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..081aa5c --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Database +DATABASE_URL=postgres://calendarapi:password@localhost:5432/calendarapi?sslmode=disable + +# Auth +JWT_SECRET=dev-secret-change-me + +# Redis (optional — enables background reminder jobs) +# REDIS_ADDR=localhost:6379 + +# Server +SERVER_PORT=3019 +ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9507bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Environment and secrets +.env +.env.local +.env.*.local +*.pem + +# Go +/bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +vendor/ + +# Build and test +*.test +*.out +coverage.html +coverage.out +*.prof + +# IDE and editors +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ + +# OS +.DS_Store +Thumbs.db + +# Logs and temp +*.log +tmp/ +temp/ +.cache/ + +# sqlc generated (optional: uncomment if you regenerate and don't want to track) +# internal/repository/*.sql.go + +# Local dev (binaries in project root only) +/server +/calendarapi diff --git a/README.md b/README.md new file mode 100644 index 0000000..f420a3b --- /dev/null +++ b/README.md @@ -0,0 +1,279 @@ +# Calendar & Contacts API + +A production-grade REST API for calendar management, event scheduling, contacts, availability queries, and public booking links. Built with Go, PostgreSQL, and designed for human users, AI agents, and programmatic automation. + +## Features + +- **Calendars** - Create, share, and manage multiple calendars with role-based access (owner/editor/viewer) +- **Events** - Full CRUD with recurring events (RFC 5545 RRULE), reminders, attendees, tags, and attachments +- **Contacts** - Personal contact management with search +- **Availability** - Query busy/free time across calendars +- **Booking Links** - Public scheduling pages with configurable working hours, duration, and buffer time +- **ICS Import/Export** - Standard iCalendar format compatibility +- **Dual Auth** - JWT tokens for interactive use, scoped API keys for agents and automation +- **Background Jobs** - Reminder notifications via Redis + Asynq (optional) + +## Tech Stack + +| Component | Technology | +|-----------|-----------| +| Language | Go 1.24 | +| Router | Chi | +| Database | PostgreSQL 15+ | +| Query Layer | sqlc | +| Migrations | golang-migrate | +| Auth | JWT (golang-jwt/jwt/v5) + bcrypt | +| Recurrence | rrule-go (RFC 5545) | +| Background Jobs | Asynq (Redis) | +| API Docs | OpenAPI 3.1.0 + Swagger UI | + +## Quick Start + +### Prerequisites + +- Go 1.24+ +- PostgreSQL 15+ +- Redis (optional, for background reminder jobs) + +### Setup + +1. Clone the repository and navigate to the project directory. + +2. Copy the environment file and configure it: + +```bash +cp .env.example .env +``` + +3. Edit `.env` with your database credentials: + +```env +DATABASE_URL=postgres://calendarapi:password@localhost:5432/calendarapi?sslmode=disable +JWT_SECRET=your-secret-key-change-me +SERVER_PORT=3019 +ENV=development +# REDIS_ADDR=localhost:6379 # uncomment to enable background jobs +``` + +4. Create the database: + +```bash +createdb calendarapi +``` + +5. Run the server (migrations run automatically on startup): + +```bash +go run cmd/server/main.go +``` + +The server starts on `http://localhost:3019`. Swagger UI is available at `http://localhost:3019/docs`. + +## API Documentation + +| Resource | URL | +|----------|-----| +| Swagger UI | http://localhost:3019/docs | +| OpenAPI Spec | http://localhost:3019/openapi.json | +| LLM Reference | [llms.txt](llms.txt) | +| Agent Skill | [SKILL.md](SKILL.md) | + +## Authentication + +### JWT (for users) + +Register or login to receive an access token (15 min) and refresh token: + +```bash +# Register +curl -X POST http://localhost:3019/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "securepassword123", "timezone": "UTC"}' + +# Login +curl -X POST http://localhost:3019/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "securepassword123"}' +``` + +Use the access token in subsequent requests: + +``` +Authorization: Bearer +``` + +### API Keys (for agents) + +Create a scoped API key for long-lived programmatic access: + +```bash +curl -X POST http://localhost:3019/api-keys \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-agent", + "scopes": { + "calendars": ["read", "write"], + "events": ["read", "write"], + "contacts": ["read", "write"], + "availability": ["read"], + "booking": ["write"] + } + }' +``` + +Use the returned token in subsequent requests: + +``` +X-API-Key: +``` + +## API Endpoints + +### Auth + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/auth/register` | None | Create account | +| POST | `/auth/login` | None | Login | +| POST | `/auth/refresh` | None | Refresh JWT tokens | +| POST | `/auth/logout` | Yes | Revoke refresh token | +| GET | `/auth/me` | Yes | Get current user | + +### Users + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/users/me` | Get profile | +| PUT | `/users/me` | Update profile | +| DELETE | `/users/me` | Delete account | + +### API Keys + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api-keys` | Create API key | +| GET | `/api-keys` | List API keys | +| DELETE | `/api-keys/{id}` | Revoke API key | + +### Calendars + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/calendars` | calendars:read | List calendars | +| POST | `/calendars` | calendars:write | Create calendar | +| GET | `/calendars/{id}` | calendars:read | Get calendar | +| PUT | `/calendars/{id}` | calendars:write | Update calendar | +| DELETE | `/calendars/{id}` | calendars:write | Delete calendar | +| POST | `/calendars/{id}/share` | calendars:write | Share calendar | +| GET | `/calendars/{id}/members` | calendars:read | List members | +| DELETE | `/calendars/{id}/members/{userID}` | calendars:write | Remove member | + +### Events + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/events` | events:read | List events in time range | +| POST | `/events` | events:write | Create event | +| GET | `/events/{id}` | events:read | Get event | +| PUT | `/events/{id}` | events:write | Update event | +| DELETE | `/events/{id}` | events:write | Delete event | +| POST | `/events/{id}/reminders` | events:write | Add reminders | +| DELETE | `/events/{id}/reminders/{reminderID}` | events:write | Remove reminder | +| POST | `/events/{id}/attendees` | events:write | Add attendees | +| PUT | `/events/{id}/attendees/{attendeeID}` | events:write | Update RSVP | +| DELETE | `/events/{id}/attendees/{attendeeID}` | events:write | Remove attendee | + +### Contacts + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/contacts` | contacts:read | List contacts | +| POST | `/contacts` | contacts:write | Create contact | +| GET | `/contacts/{id}` | contacts:read | Get contact | +| PUT | `/contacts/{id}` | contacts:write | Update contact | +| DELETE | `/contacts/{id}` | contacts:write | Delete contact | + +### Availability + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/availability` | availability:read | Get busy blocks for a calendar | + +### Booking + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/calendars/{id}/booking-link` | booking:write | Create booking link | +| GET | `/booking/{token}/availability` | None | Get available slots | +| POST | `/booking/{token}/reserve` | None | Reserve a slot | + +### ICS + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/calendars/{id}/export.ics` | calendars:read | Export as ICS | +| POST | `/calendars/import` | calendars:write | Import ICS file | + +## Project Structure + +``` +calendarapi/ + cmd/server/main.go Entry point + internal/ + api/ + routes.go Route definitions + handlers/ HTTP handlers + openapi/ OpenAPI spec files + Swagger UI + auth/ JWT manager + config/ Environment config + middleware/ Auth, rate limiting, scope enforcement + models/ Domain models + repository/ sqlc-generated database queries + scheduler/ Asynq background job scheduler + service/ Business logic layer + utils/ Shared utilities + migrations/ SQL migration files + sqlc/ sqlc configuration + about/ Design specifications + overview.md Architecture and data model + logic.md Business logic rules + details.md Endpoint contracts + SKILL.md AI agent integration guide + llms.txt LLM-optimized API reference + .env.example Environment template + go.mod + go.sum +``` + +## Architecture + +``` +Client (Web / Mobile / Agent) + | + HTTP REST API (Chi router) + | + Middleware (Auth, Rate Limiting, Scope Enforcement) + | + Handler Layer (Parse request, validate, return JSON) + | + Service Layer (Business logic, permissions, transactions) + | + Repository Layer (sqlc queries) + | + PostgreSQL +``` + +## Design Principles + +- **Stateless** - JWT or API key on every request, no server-side sessions +- **UTC everywhere** - All timestamps stored in UTC, original timezone preserved +- **Soft deletes** - No data is permanently destroyed +- **Strict ownership** - Ownership derived from auth context, never from client input +- **Cursor pagination** - Efficient, consistent pagination on all list endpoints +- **Scoped API keys** - Fine-grained permission control for agent access +- **Race-safe booking** - Database transactions with row locking prevent double-booking + +## License + +MIT diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..0a0df7c --- /dev/null +++ b/SKILL.md @@ -0,0 +1,396 @@ +# Calendar & Contacts API - Agent Skill + +## Purpose + +This skill enables AI agents to manage calendars, events, contacts, availability, and bookings through the Calendar & Contacts REST API. The API runs on `http://localhost:3019` by default. + +## Credentials + +Credentials are stored in a file called `calendarcredentials.txt` in the agent's working directory or project root. If the file does not exist, create it after registration. + +### Format of calendarcredentials.txt + +``` +BASE_URL=http://localhost:3019 +EMAIL=agent@example.com +PASSWORD=securepassword123 +API_KEY= +ACCESS_TOKEN= +REFRESH_TOKEN= +``` + +- `BASE_URL` - the API server URL (default `http://localhost:3019`) +- `EMAIL` / `PASSWORD` - account credentials used for login and token refresh +- `API_KEY` - scoped API key for long-lived programmatic access (preferred for agents) +- `ACCESS_TOKEN` / `REFRESH_TOKEN` - short-lived JWT tokens from login + +### Credential Priority + +1. Read `calendarcredentials.txt` first. +2. If an `API_KEY` is present, use it via `X-API-Key` header for all requests. +3. If only `ACCESS_TOKEN` is available, use `Authorization: Bearer `. +4. If the access token returns 401, call `POST /auth/refresh` with the refresh token, update the file with new tokens, and retry. +5. If no tokens exist, call `POST /auth/login` with email/password, store the returned tokens, and proceed. + +## Authentication + +### Two auth methods + +| Method | Header | Lifetime | Best for | +|--------|--------|----------|----------| +| JWT | `Authorization: Bearer ` | 15 min access / 7-30 day refresh | Interactive sessions | +| API Key | `X-API-Key: ` | Until revoked | Agents and automation | + +### Register a new account + +``` +POST /auth/register +Content-Type: application/json + +{"email": "agent@example.com", "password": "securepassword123", "timezone": "UTC"} +``` + +Returns `user`, `access_token`, `refresh_token`. Save all to `calendarcredentials.txt`. + +### Login + +``` +POST /auth/login +Content-Type: application/json + +{"email": "agent@example.com", "password": "securepassword123"} +``` + +Returns `user`, `access_token`, `refresh_token`. + +### Refresh tokens + +``` +POST /auth/refresh +Content-Type: application/json + +{"refresh_token": ""} +``` + +Returns new `access_token` and `refresh_token`. Update `calendarcredentials.txt`. + +### Create an API key (recommended for agents) + +``` +POST /api-keys +Authorization: Bearer +Content-Type: application/json + +{ + "name": "agent-full-access", + "scopes": { + "calendars": ["read", "write"], + "events": ["read", "write"], + "contacts": ["read", "write"], + "availability": ["read"], + "booking": ["write"] + } +} +``` + +Response includes a `token` field. This is the API key. It is only returned once. Save it to `calendarcredentials.txt` immediately. + +### Agent bootstrap sequence + +1. Check if `calendarcredentials.txt` exists. +2. If not: register, create API key, save credentials. +3. If yes: read credentials and authenticate using API key or JWT. + +## API Reference + +Base URL: `http://localhost:3019` + +All request/response bodies are JSON. All timestamps are RFC3339 UTC. All list endpoints return `{"items": [...], "page": {"limit": N, "next_cursor": "..."}}`. + +### Calendars + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/calendars` | calendars:read | List all calendars (owned + shared) | +| POST | `/calendars` | calendars:write | Create a calendar | +| GET | `/calendars/{id}` | calendars:read | Get a calendar by ID | +| PUT | `/calendars/{id}` | calendars:write | Update name, color, is_public | +| DELETE | `/calendars/{id}` | calendars:write | Soft-delete (owner only) | +| POST | `/calendars/{id}/share` | calendars:write | Share with another user by email | +| GET | `/calendars/{id}/members` | calendars:read | List calendar members and roles | +| DELETE | `/calendars/{id}/members/{userID}` | calendars:write | Remove a member (owner only) | + +#### Create calendar + +``` +POST /calendars +{"name": "Work", "color": "#22C55E"} +``` + +#### Share a calendar + +``` +POST /calendars/{id}/share +{"target": {"email": "other@example.com"}, "role": "editor"} +``` + +### Events + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/events?start=...&end=...` | events:read | List events in time range | +| POST | `/events` | events:write | Create an event | +| GET | `/events/{id}` | events:read | Get event with reminders/attendees | +| PUT | `/events/{id}` | events:write | Update an event | +| DELETE | `/events/{id}` | events:write | Soft-delete an event | + +#### List events (required: start, end) + +``` +GET /events?start=2026-03-01T00:00:00Z&end=2026-03-31T23:59:59Z&calendar_id= +``` + +Optional filters: `calendar_id`, `search`, `tag`, `limit` (max 200), `cursor`. + +Recurring events are automatically expanded into individual occurrences within the requested range. Occurrences have `is_occurrence: true` with `occurrence_start_time` / `occurrence_end_time`. + +#### Create event + +``` +POST /events +{ + "calendar_id": "", + "title": "Team standup", + "start_time": "2026-03-01T14:00:00Z", + "end_time": "2026-03-01T14:30:00Z", + "timezone": "America/New_York", + "all_day": false, + "recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR", + "reminders": [10, 60], + "tags": ["work", "standup"] +} +``` + +#### Update event + +``` +PUT /events/{id} +{"title": "Updated title", "start_time": "2026-03-01T15:00:00Z", "end_time": "2026-03-01T16:00:00Z"} +``` + +### Reminders + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| POST | `/events/{id}/reminders` | events:write | Add reminders (minutes before) | +| DELETE | `/events/{id}/reminders/{reminderID}` | events:write | Remove a reminder | + +``` +POST /events/{id}/reminders +{"minutes_before": [5, 15, 60]} +``` + +### Attendees + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| POST | `/events/{id}/attendees` | events:write | Add attendees by email or user_id | +| PUT | `/events/{id}/attendees/{attendeeID}` | events:write | Update RSVP status | +| DELETE | `/events/{id}/attendees/{attendeeID}` | events:write | Remove attendee | + +``` +POST /events/{id}/attendees +{"attendees": [{"email": "guest@example.com"}, {"user_id": ""}]} +``` + +Status values: `pending`, `accepted`, `declined`, `tentative`. + +### Contacts + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/contacts` | contacts:read | List contacts (optional: search, limit, cursor) | +| POST | `/contacts` | contacts:write | Create a contact | +| GET | `/contacts/{id}` | contacts:read | Get a contact | +| PUT | `/contacts/{id}` | contacts:write | Update a contact | +| DELETE | `/contacts/{id}` | contacts:write | Soft-delete a contact | + +``` +POST /contacts +{"first_name": "Jane", "last_name": "Doe", "email": "jane@example.com", "phone": "+15551234567", "company": "Acme", "notes": "Met at conference"} +``` + +At least one identifying field (first_name, last_name, email, or phone) is required. + +### Availability + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/availability?calendar_id=...&start=...&end=...` | availability:read | Get busy blocks for a calendar | + +``` +GET /availability?calendar_id=&start=2026-03-01T00:00:00Z&end=2026-03-07T23:59:59Z +``` + +Returns `busy` array of `{start, end, event_id}` blocks. Includes expanded recurring event occurrences. + +### Booking (public, no auth required for GET availability and POST reserve) + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/calendars/{id}/booking-link` | booking:write | Create a public booking link | +| GET | `/booking/{token}/availability?start=...&end=...` | None | Get available slots | +| POST | `/booking/{token}/reserve` | None | Reserve a time slot | + +#### Create booking link + +``` +POST /calendars/{id}/booking-link +{ + "duration_minutes": 30, + "buffer_minutes": 0, + "timezone": "America/New_York", + "working_hours": { + "mon": [{"start": "09:00", "end": "17:00"}], + "tue": [{"start": "09:00", "end": "17:00"}], + "wed": [{"start": "09:00", "end": "17:00"}], + "thu": [{"start": "09:00", "end": "17:00"}], + "fri": [{"start": "09:00", "end": "17:00"}], + "sat": [], + "sun": [] + }, + "active": true +} +``` + +#### Reserve a slot + +``` +POST /booking/{token}/reserve +{"name": "Visitor", "email": "visitor@example.com", "slot_start": "2026-03-03T10:00:00Z", "slot_end": "2026-03-03T10:30:00Z", "notes": "Intro call"} +``` + +Returns 409 CONFLICT if the slot is no longer available. + +### ICS Import/Export + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| GET | `/calendars/{id}/export.ics` | calendars:read | Export calendar as ICS file | +| POST | `/calendars/import` | calendars:write | Import ICS file (multipart/form-data) | + +Export returns `Content-Type: text/calendar`. + +Import requires multipart form with `calendar_id` (uuid) and `file` (.ics file). + +### Users + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/users/me` | Get current user profile | +| PUT | `/users/me` | Update timezone | +| DELETE | `/users/me` | Soft-delete account and all data | + +### API Keys + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api-keys` | Create API key with scopes | +| GET | `/api-keys` | List API keys | +| DELETE | `/api-keys/{id}` | Revoke an API key | + +### Auth + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/auth/register` | None | Create account | +| POST | `/auth/login` | None | Login | +| POST | `/auth/refresh` | None | Refresh JWT tokens | +| POST | `/auth/logout` | JWT | Revoke refresh token | +| GET | `/auth/me` | JWT/Key | Get authenticated user | + +## Error Handling + +All errors return: + +```json +{"error": "Human-readable message", "code": "MACHINE_CODE", "details": "optional"} +``` + +| HTTP | Code | Meaning | +|------|------|---------| +| 400 | VALIDATION_ERROR | Invalid input | +| 401 | AUTH_REQUIRED | No credentials provided | +| 401 | AUTH_INVALID | Invalid or expired token | +| 403 | FORBIDDEN | Insufficient permission or scope | +| 404 | NOT_FOUND | Resource not found | +| 409 | CONFLICT | Duplicate or slot unavailable | +| 429 | RATE_LIMITED | Too many requests | +| 500 | INTERNAL | Server error | + +On 401: refresh the access token or re-login. On 403 with an API key: the key may lack required scopes. On 429: back off and retry after a delay. + +## Pagination + +All list endpoints use cursor-based pagination: + +``` +GET /events?start=...&end=...&limit=50&cursor= +``` + +Response always includes: + +```json +{ + "items": [], + "page": {"limit": 50, "next_cursor": "abc123"} +} +``` + +When `next_cursor` is `null`, there are no more pages. To fetch the next page, pass the cursor value as the `cursor` query parameter. + +## Common Agent Workflows + +### Workflow: Schedule a meeting + +1. `GET /calendars` - pick the target calendar. +2. `GET /availability?calendar_id=&start=...&end=...` - find a free slot. +3. `POST /events` - create the event in the free slot. +4. `POST /events/{id}/attendees` - invite attendees by email. +5. `POST /events/{id}/reminders` - set reminders. + +### Workflow: Find free time across calendars + +1. `GET /calendars` - list all calendars. +2. For each calendar: `GET /availability?calendar_id=&start=...&end=...` +3. Compute intersection of free time. + +### Workflow: Set up a public booking page + +1. `GET /calendars` - pick the calendar. +2. `POST /calendars/{id}/booking-link` - create the link with working hours. +3. Share the returned `public_url` or `token` with the person who needs to book. + +### Workflow: Import external calendar + +1. `GET /calendars` or `POST /calendars` - ensure target calendar exists. +2. `POST /calendars/import` - upload `.ics` file as multipart form data with `calendar_id`. + +## Important Constraints + +- Passwords must be at least 10 characters. +- Calendar names: 1-80 characters. +- Event titles: 1-140 characters. +- Colors: hex format `#RRGGBB`. +- Timezones: valid IANA timezone names (e.g., `America/New_York`, `UTC`). +- Recurrence rules: RFC 5545 RRULE format (e.g., `FREQ=WEEKLY;BYDAY=MO,WE,FR`). +- Reminder minutes_before: 0-10080 (up to 7 days). +- Event time ranges for listing: max 1 year span. +- Pagination limit: 1-200, default 50. +- Contacts require at least one of: first_name, last_name, email, phone. +- Only calendar owners can share, delete calendars, or create booking links. +- Editors can create/update/delete events. Viewers are read-only. + +## OpenAPI Spec + +The full OpenAPI 3.1.0 specification is available at `GET /openapi.json`. Interactive Swagger UI is at `GET /docs`. diff --git a/about/details.md b/about/details.md new file mode 100644 index 0000000..9d79b13 --- /dev/null +++ b/about/details.md @@ -0,0 +1,899 @@ +# Calendar & Contacts API + +## details.md + +This document defines exact endpoint contracts: request/response schemas, field constraints, pagination formats, examples, and implementation notes so a developer can build the API and a frontend without guessing. + +All timestamps are RFC3339 strings. +All stored times are UTC. + +--- + +# 1. Conventions + +## 1.1 Base URL + +* Local: [http://localhost:8080](http://localhost:8080) +* Production: [https://api.example.com](https://api.example.com) + +All endpoints are under the root path. + +--- + +## 1.2 Authentication Headers + +JWT: + +* Authorization: Bearer + +API key: + +* X-API-Key: + +If both are present, JWT takes precedence. + +--- + +## 1.3 Standard Response Envelope + +This API returns plain JSON objects. +For list endpoints, a consistent list envelope is required. + +List response envelope: +{ +"items": [ ... ], +"page": { +"limit": 50, +"next_cursor": "opaque-or-null" +} +} + +--- + +## 1.4 Cursor Pagination + +Query params: + +* limit (optional, default 50, max 200) +* cursor (optional) + +Cursor meaning: + +* Opaque base64url string encoding the tuple: + + * last_sort_time (RFC3339) + * last_id (uuid) + +Sorting rule for paginated lists: + +* Primary: start_time asc (or created_at for contacts) +* Secondary: id asc + +If cursor is provided: + +* Return records strictly greater than the tuple. + +--- + +## 1.5 Error Format + +All error responses: +{ +"error": "Human readable message", +"code": "MACHINE_READABLE_CODE", +"details": "Optional string or object" +} + +HTTP mapping: + +* 400 VALIDATION_ERROR +* 401 AUTH_REQUIRED / AUTH_INVALID +* 403 FORBIDDEN +* 404 NOT_FOUND +* 409 CONFLICT +* 429 RATE_LIMITED +* 500 INTERNAL + +--- + +# 2. Data Schemas + +## 2.1 User + +{ +"id": "uuid", +"email": "string", +"timezone": "string", +"created_at": "RFC3339", +"updated_at": "RFC3339" +} + +Constraints: + +* email lowercase +* timezone must be IANA timezone name, default "UTC" + +--- + +## 2.2 Calendar + +{ +"id": "uuid", +"name": "string", +"color": "string", +"is_public": true, +"role": "owner|editor|viewer", +"created_at": "RFC3339", +"updated_at": "RFC3339" +} + +Constraints: + +* name 1..80 +* color is hex like "#RRGGBB" (optional) + +--- + +## 2.3 Event + +{ +"id": "uuid", +"calendar_id": "uuid", +"title": "string", +"description": "string|null", +"location": "string|null", +"start_time": "RFC3339-UTC", +"end_time": "RFC3339-UTC", +"timezone": "string", +"all_day": false, +"recurrence_rule": "string|null", +"created_by": "uuid", +"updated_by": "uuid", +"created_at": "RFC3339", +"updated_at": "RFC3339", +"reminders": [ +{"id": "uuid", "minutes_before": 10} +], +"attendees": [ +{"id": "uuid", "user_id": "uuid|null", "email": "string|null", "status": "pending|accepted|declined|tentative"} +], +"tags": ["string"], +"attachments": [ +{"id": "uuid", "file_url": "string"} +] +} + +Constraints: + +* title 1..140 +* timezone IANA name +* recurrence_rule must be valid RFC5545 RRULE when present + +--- + +## 2.4 Contact + +{ +"id": "uuid", +"first_name": "string|null", +"last_name": "string|null", +"email": "string|null", +"phone": "string|null", +"company": "string|null", +"notes": "string|null", +"created_at": "RFC3339", +"updated_at": "RFC3339" +} + +Constraints: + +* At least one of: first_name, last_name, email, phone must be present + +--- + +# 3. Endpoint Contracts + +## 3.1 Auth + +### POST /auth/register + +Request: +{ +"email": "[user@example.com](mailto:user@example.com)", +"password": "string", +"timezone": "America/Asuncion" +} + +Rules: + +* timezone optional +* server creates default calendar + +Response 200: +{ +"user": { ...User }, +"access_token": "string", +"refresh_token": "string" +} + +Errors: + +* 400 VALIDATION_ERROR +* 409 CONFLICT (email already exists) + +--- + +### POST /auth/login + +Request: +{ +"email": "[user@example.com](mailto:user@example.com)", +"password": "string" +} + +Response 200: +{ +"user": { ...User }, +"access_token": "string", +"refresh_token": "string" +} + +Errors: + +* 401 AUTH_INVALID + +--- + +### POST /auth/refresh + +Request: +{ +"refresh_token": "string" +} + +Response 200: +{ +"access_token": "string", +"refresh_token": "string" +} + +Errors: + +* 401 AUTH_INVALID + +--- + +### POST /auth/logout + +Request: +{ +"refresh_token": "string" +} + +Response 200: +{ +"ok": true +} + +--- + +### GET /auth/me + +Response 200: +{ +"user": { ...User } +} + +--- + +## 3.2 API Keys + +### POST /api-keys + +Request: +{ +"name": "My agent key", +"scopes": { +"calendars": ["read", "write"], +"events": ["read", "write"], +"contacts": ["read", "write"], +"availability": ["read"], +"booking": ["write"] +} +} + +Response 200: +{ +"id": "uuid", +"name": "My agent key", +"created_at": "RFC3339", +"token": "RAW_TOKEN_RETURNED_ONCE" +} + +--- + +### GET /api-keys + +Response 200: +{ +"items": [ +{"id": "uuid", "name": "string", "created_at": "RFC3339", "revoked_at": "RFC3339|null"} +], +"page": {"limit": 50, "next_cursor": null} +} + +--- + +### DELETE /api-keys/{id} + +Response 200: +{ +"ok": true +} + +--- + +## 3.3 Users + +### GET /users/me + +Response 200: +{ +"user": { ...User } +} + +--- + +### PUT /users/me + +Request: +{ +"timezone": "America/Asuncion" +} + +Response 200: +{ +"user": { ...User } +} + +--- + +### DELETE /users/me + +Response 200: +{ +"ok": true +} + +--- + +## 3.4 Calendars + +### GET /calendars + +Response 200: +{ +"items": [ ...Calendar ], +"page": {"limit": 50, "next_cursor": null} +} + +--- + +### POST /calendars + +Request: +{ +"name": "Work", +"color": "#22C55E" +} + +Response 200: +{ +"calendar": { ...Calendar } +} + +--- + +### GET /calendars/{id} + +Response 200: +{ +"calendar": { ...Calendar } +} + +--- + +### PUT /calendars/{id} + +Request: +{ +"name": "Work Calendar", +"color": "#22C55E", +"is_public": false +} + +Rules: + +* is_public only owner + +Response 200: +{ +"calendar": { ...Calendar } +} + +--- + +### DELETE /calendars/{id} + +Response 200: +{ +"ok": true +} + +--- + +## 3.5 Calendar Sharing + +### POST /calendars/{id}/share + +Request: +{ +"target": {"email": "[other@example.com](mailto:other@example.com)"}, +"role": "editor" +} + +Response 200: +{ +"ok": true +} + +--- + +### GET /calendars/{id}/members + +Response 200: +{ +"items": [ +{"user_id": "uuid", "email": "string", "role": "owner|editor|viewer"} +], +"page": {"limit": 50, "next_cursor": null} +} + +--- + +### DELETE /calendars/{id}/members/{user_id} + +Response 200: +{ +"ok": true +} + +--- + +## 3.6 Events + +### GET /events + +Query: + +* start (required) RFC3339 +* end (required) RFC3339 +* calendar_id (optional) +* search (optional) +* tag (optional) +* limit, cursor + +Response 200: +{ +"items": [ ...Event ], +"page": {"limit": 50, "next_cursor": "string|null"} +} + +Notes: + +* Must include expanded recurrence occurrences inside requested range. +* For recurrence expansion, include occurrences as separate items with: + + * id = master event id + * occurrence_start_time, occurrence_end_time (recommended fields) + +Recommended recurrence occurrence representation: +{ +"id": "uuid-master", +"is_occurrence": true, +"occurrence_start_time": "RFC3339", +"occurrence_end_time": "RFC3339", +...base event fields... +} + +--- + +### POST /events + +Request: +{ +"calendar_id": "uuid", +"title": "Meeting", +"description": "Project sync", +"location": "Zoom", +"start_time": "2026-03-01T14:00:00-03:00", +"end_time": "2026-03-01T15:00:00-03:00", +"timezone": "America/Asuncion", +"all_day": false, +"recurrence_rule": null, +"reminders": [10, 60], +"tags": ["work", "sync"] +} + +Response 200: +{ +"event": { ...Event } +} + +Errors: + +* 403 FORBIDDEN +* 400 VALIDATION_ERROR + +--- + +### GET /events/{id} + +Response 200: +{ +"event": { ...Event } +} + +--- + +### PUT /events/{id} + +Request: +{ +"title": "Updated title", +"start_time": "2026-03-01T15:00:00-03:00", +"end_time": "2026-03-01T16:00:00-03:00", +"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR" +} + +Response 200: +{ +"event": { ...Event } +} + +--- + +### DELETE /events/{id} + +Response 200: +{ +"ok": true +} + +--- + +## 3.7 Event Reminders + +### POST /events/{id}/reminders + +Request: +{ +"minutes_before": [5, 15, 60] +} + +Response 200: +{ +"event": { ...Event } +} + +--- + +### DELETE /events/{id}/reminders/{reminder_id} + +Response 200: +{ +"ok": true +} + +--- + +## 3.8 Attendees + +### POST /events/{id}/attendees + +Request: +{ +"attendees": [ +{"email": "[guest@example.com](mailto:guest@example.com)"}, +{"user_id": "uuid"} +] +} + +Response 200: +{ +"event": { ...Event } +} + +--- + +### PUT /events/{id}/attendees/{attendee_id} + +Request: +{ +"status": "accepted" +} + +Rules: + +* Organizer can edit any attendee +* Attendee can edit own status + +Response 200: +{ +"event": { ...Event } +} + +--- + +### DELETE /events/{id}/attendees/{attendee_id} + +Response 200: +{ +"ok": true +} + +--- + +## 3.9 Contacts + +### GET /contacts + +Query: + +* search (optional) +* limit, cursor + +Response 200: +{ +"items": [ ...Contact ], +"page": {"limit": 50, "next_cursor": "string|null"} +} + +--- + +### POST /contacts + +Request: +{ +"first_name": "Jane", +"last_name": "Doe", +"email": "[jane@example.com](mailto:jane@example.com)", +"phone": "+595981000000", +"company": "Example SA", +"notes": "Met at event" +} + +Response 200: +{ +"contact": { ...Contact } +} + +--- + +### GET /contacts/{id} + +Response 200: +{ +"contact": { ...Contact } +} + +--- + +### PUT /contacts/{id} + +Request: +{ +"notes": "Updated notes" +} + +Response 200: +{ +"contact": { ...Contact } +} + +--- + +### DELETE /contacts/{id} + +Response 200: +{ +"ok": true +} + +--- + +## 3.10 Availability + +### GET /availability + +Query: + +* calendar_id (required) +* start (required) +* end (required) + +Response 200: +{ +"calendar_id": "uuid", +"range_start": "RFC3339", +"range_end": "RFC3339", +"busy": [ +{"start": "RFC3339", "end": "RFC3339", "event_id": "uuid"} +] +} + +--- + +## 3.11 Booking Links + +### POST /calendars/{id}/booking-link + +Request: +{ +"duration_minutes": 30, +"buffer_minutes": 0, +"timezone": "America/Asuncion", +"working_hours": { +"mon": [{"start": "09:00", "end": "17:00"}], +"tue": [{"start": "09:00", "end": "17:00"}], +"wed": [{"start": "09:00", "end": "17:00"}], +"thu": [{"start": "09:00", "end": "17:00"}], +"fri": [{"start": "09:00", "end": "17:00"}], +"sat": [], +"sun": [] +}, +"active": true +} + +Response 200: +{ +"token": "string", +"public_url": "[https://app.example.com/booking/](https://app.example.com/booking/)", +"settings": { ...same-as-request } +} + +--- + +### GET /booking/{token}/availability + +Query: + +* start +* end + +Response 200: +{ +"token": "string", +"timezone": "America/Asuncion", +"duration_minutes": 30, +"slots": [ +{"start": "RFC3339", "end": "RFC3339"} +] +} + +--- + +### POST /booking/{token}/reserve + +Request: +{ +"name": "Visitor Name", +"email": "[visitor@example.com](mailto:visitor@example.com)", +"slot_start": "RFC3339", +"slot_end": "RFC3339", +"notes": "Optional" +} + +Response 200: +{ +"ok": true, +"event": { ...Event } +} + +Errors: + +* 409 CONFLICT if slot no longer available + +--- + +## 3.12 ICS + +### GET /calendars/{id}/export.ics + +Response: + +* Content-Type: text/calendar +* Body: ICS format + +--- + +### POST /calendars/import + +Request: + +* multipart/form-data + + * calendar_id (uuid) + * file (.ics) + +Response 200: +{ +"ok": true, +"imported": { +"events": 12 +} +} + +--- + +# 4. Validation Constraints Summary + +Users: + +* email unique +* password >=10 chars + +Calendars: + +* name 1..80 +* color valid hex + +Events: + +* title 1..140 +* end_time > start_time +* timezone valid IANA +* reminders minutes_before must be 0..10080 + +Contacts: + +* at least one identifying field + +--- + +# 5. Implementation Notes (Go) + +## Handler Layer + +* Parse JSON +* Validate basic constraints +* Pass to service + +## Service Layer + +* Permission enforcement +* Ownership validation +* Time conversion to UTC +* Recurrence validation and expansion +* Reminder job scheduling +* Transaction management for booking reservations + +## Repository Layer + +* sqlc queries +* No business logic + +--- + +# 6. Frontend and Agent Integration Guarantees + +* The API must remain consistent in response shape. +* List endpoints always return items + page. +* All objects include created_at/updated_at. +* Calendar list includes role. +* Event list returns occurrences for recurrence within range. +* Booking endpoints require no auth. + +--- + +End of details.md diff --git a/about/logic.md b/about/logic.md new file mode 100644 index 0000000..97e4bb1 --- /dev/null +++ b/about/logic.md @@ -0,0 +1,501 @@ +# Calendar & Contacts API + +## logic.md + +This document defines the COMPLETE business logic layer of the Calendar & Contacts API. + +It explains: + +* Permission enforcement +* Ownership rules +* Validation rules +* Recurrence engine behavior +* Reminder processing +* Availability calculation +* Booking logic +* Transaction boundaries +* Audit requirements +* Edge case handling + +This file MUST be treated as the authoritative source for backend logic. + +--- + +## GLOBAL INVARIANTS + +1. All timestamps stored in UTC. +2. All API timestamps are RFC3339 strings. +3. Ownership is ALWAYS derived from auth context. +4. Soft deletes are enforced everywhere. +5. No endpoint may leak data across users. +6. All mutations must write audit log entries. + +--- + +1. AUTHENTICATION & CONTEXT + +--- + +Every authenticated request must result in a RequestContext struct: + +RequestContext: + +* user_id (uuid) +* auth_method ("jwt" | "api_key") +* scopes (if api_key) + +JWT auth: + +* Validate signature +* Validate expiration +* Extract user_id + +API key auth: + +* Hash provided key +* Lookup in api_keys +* Ensure revoked_at is NULL +* Load scopes + +If neither provided → AUTH_REQUIRED + +If invalid → AUTH_INVALID + +--- + +2. PERMISSION MODEL + +--- + +Calendar roles: + +* owner +* editor +* viewer + +Permission matrix: + +CALENDAR ACTIONS + +* owner: full access +* editor: read calendar, CRUD events +* viewer: read-only + +EVENT ACTIONS + +* owner/editor: create/update/delete +* viewer: read + +CONTACT ACTIONS + +* Only owner of contacts can CRUD + +BOOKING + +* Only owner can create booking links + +API KEY SCOPE ENFORCEMENT +Each endpoint maps to required scope. +Example: + +* GET /calendars → calendars:read +* POST /events → events:write +* GET /contacts → contacts:read + +If scope missing → FORBIDDEN + +--- + +3. USER LOGIC + +--- + +REGISTER + +* Email lowercase +* Must be unique +* Password >=10 chars +* Hash with bcrypt cost >=12 +* Create default calendar +* Return tokens + +DELETE USER + +* Soft delete user +* Soft delete calendars +* Soft delete events +* Soft delete contacts +* Revoke API keys + +--- + +4. CALENDAR LOGIC + +--- + +CREATE + +* owner_id = authenticated user +* name required (1..80) +* color optional hex validation + +LIST +Return calendars where: + +* owner_id = user_id + OR +* calendar_members.user_id = user_id + +Include role in response. + +DELETE + +* Only owner +* Soft delete calendar +* Soft delete all related events + +SHARING + +* Only owner can share +* Cannot share with self +* Upsert membership row + +REMOVE MEMBER + +* Only owner +* Cannot remove owner + +--- + +5. EVENT LOGIC + +--- + +CREATE EVENT +Validation: + +* calendar exists +* user has editor or owner role +* title 1..140 +* end_time > start_time +* timezone valid IANA + +Time handling: + +* Convert start_time to UTC +* Convert end_time to UTC +* Store original timezone string + +Overlap rule: + +* Overlap allowed by default +* Booking system enforces no-overlap + +If recurrence_rule provided: + +* Validate via rrule-go +* Store string + +If reminders provided: + +* Insert reminders +* Schedule jobs + +UPDATE EVENT + +* Same permission check +* Re-validate time constraints +* If recurrence changed → validate again +* Reschedule reminders + +DELETE EVENT + +* Soft delete + +--- + +6. RECURRENCE ENGINE + +--- + +Recurring events are NOT pre-expanded. + +Storage: + +* recurrence_rule string on master event + +Expansion occurs ONLY during: + +* GET /events +* GET /events/{id}/occurrences + +Algorithm: + +For each event in DB where: + +* event.start_time <= range_end + +If no recurrence_rule: + +* Include if intersects range + +If recurrence_rule present: + +* Initialize rrule with DTSTART = event.start_time +* Generate occurrences within [range_start, range_end] +* For each occurrence: + + * Check against exceptions + * Create virtual occurrence object + +Occurrence response must include: + +* is_occurrence = true +* occurrence_start_time +* occurrence_end_time + +Exceptions table: + +* event_id +* exception_date +* action ("skip") + +If occurrence matches exception_date → skip + +--- + +7. REMINDER PROCESSING + +--- + +Reminder scheduling rule: + +For each reminder (minutes_before): +trigger_time = event.start_time - minutes_before + +If trigger_time > now: + +* Enqueue Asynq job + +Job payload: + +* event_id +* reminder_id +* user_id + +Worker logic: + +* Load event +* If event.deleted_at != NULL → abort +* Send notification (webhook/email placeholder) +* Retry on failure with exponential backoff + +On event update: + +* Cancel old reminder jobs (if supported) +* Recompute schedule + +--- + +8. CONTACT LOGIC + +--- + +Contacts are strictly per-user. + +CREATE + +* Must have at least one identifying field +* email validated if present + +SEARCH + +* Case-insensitive match on: + + * first_name + * last_name + * email + * company + +DELETE + +* Soft delete + +--- + +9. AVAILABILITY LOGIC + +--- + +Endpoint requires: + +* calendar_id +* start +* end + +Permission: + +* viewer or higher + +Busy condition: +An event is busy if: + +(event.start_time < range_end) +AND +(event.end_time > range_start) + +Recurring events: + +* Expand occurrences +* Apply same intersection rule + +Return busy blocks sorted by start. + +--- + +10. BOOKING SYSTEM LOGIC + +--- + +CREATE BOOKING LINK + +* Only owner +* Generate secure random token +* Store configuration + +PUBLIC AVAILABILITY + +* No auth +* Load booking link +* Validate active +* Compute working-hour windows +* Subtract busy blocks +* Apply buffer_minutes before/after events + +RESERVATION + +* Begin DB transaction +* Re-check slot availability with overlap query +* If conflict → ROLLBACK + CONFLICT +* Insert event +* Commit + +Overlap query inside transaction: + +SELECT 1 FROM events +WHERE calendar_id = ? +AND deleted_at IS NULL +AND start_time < slot_end +AND end_time > slot_start +FOR UPDATE + +This ensures race safety. + +--- + +11. CURSOR PAGINATION + +--- + +Cursor format: +base64url(last_sort_time|last_id) + +Decoding: + +* Split by pipe +* Use as tuple comparison + +Events sorting: +ORDER BY start_time ASC, id ASC + +Contacts sorting: +ORDER BY created_at ASC, id ASC + +Limit enforcement: + +* Default 50 +* Max 200 + +--- + +12. AUDIT LOG LOGIC + +--- + +Every mutation writes: + +entity_type +entity_id +action +user_id +timestamp + +Actions examples: + +* CREATE_EVENT +* UPDATE_EVENT +* DELETE_EVENT +* SHARE_CALENDAR +* DELETE_CONTACT + +Audit writes MUST NOT fail main transaction. +If audit insert fails: + +* Log error +* Continue response + +--- + +13. ERROR RULES + +--- + +AUTH_REQUIRED → no credentials +AUTH_INVALID → invalid token +FORBIDDEN → permission denied +NOT_FOUND → entity not found or not accessible +VALIDATION_ERROR → invalid input +CONFLICT → overlap or duplicate +INTERNAL → unexpected error + +Never expose internal DB errors directly. + +--- + +14. TRANSACTION RULES + +--- + +Transactions REQUIRED for: + +* Booking reservation +* Event creation with reminders +* Event update affecting reminders +* Deleting calendar (cascade soft delete) + +Service layer must manage transactions. +Repository layer must not auto-commit. + +--- + +15. PERFORMANCE REQUIREMENTS + +--- + +All list endpoints: + +* Must use indexed columns +* Must use pagination +* Must avoid N+1 queries + +Recurring expansion must be bounded by requested range. + +Maximum recurrence expansion window allowed per request: + +* 1 year (recommended safeguard) + +If range exceeds safeguard → VALIDATION_ERROR + +--- + +This is the authoritative backend logic specification. diff --git a/about/overview.md b/about/overview.md new file mode 100644 index 0000000..76b5923 --- /dev/null +++ b/about/overview.md @@ -0,0 +1,389 @@ +# Calendar & Contacts API + +## 1. Purpose + +This system is a production‑grade Calendar and Contacts REST API written in Go. It is designed for: + +* Human users (web/mobile frontends) +* AI agents (programmatic automation) +* Future SaaS expansion +* High‑integrity multi‑user environments + +The API must be stateless, secure, permission‑enforced, timezone‑safe, and scalable. + +This document defines EXACTLY what must be built. + +--- + +# 2. System Architecture + +Client (Web / Mobile / Agent) +↓ +HTTP REST API (Go + Chi) +↓ +Service Layer (Business Logic) +↓ +Repository Layer (SQL via sqlc) +↓ +PostgreSQL + +Optional components: + +* Redis (rate limiting + job queue) +* Background Worker (reminders) +* S3-compatible storage (attachments) +* WebSocket server (real-time updates) + +--- + +# 3. Core Design Principles + +1. Stateless authentication (JWT or API key) +2. Strict ownership validation +3. All timestamps stored in UTC +4. RFC3339 format for API +5. Soft deletion instead of hard deletion +6. No trust in client-provided ownership fields +7. Clear separation between handlers, services, repositories +8. Indexes on all high-query columns + +--- + +# 4. Technology Stack + +Language: Go 1.22+ +Router: Chi +Database: PostgreSQL 15+ +Query Layer: sqlc +Migrations: golang-migrate +Auth: JWT (github.com/golang-jwt/jwt/v5) +Password hashing: bcrypt +UUID: google/uuid +Background jobs: Asynq (Redis) +WebSockets: gorilla/websocket (optional) +RRULE: rrule-go +Storage: S3 compatible (MinIO client) + +--- + +# 5. Authentication Model + +Two authentication modes must be implemented. + +## 5.1 User Authentication + +* Email + password +* Password hashed with bcrypt (cost 12+) +* JWT access token (15 min expiration) +* Refresh token (7–30 days) + +JWT payload: +{ +"user_id": "uuid", +"exp": unix_timestamp +} + +All protected endpoints require: +Authorization: Bearer + +Middleware must: + +* Validate signature +* Validate expiration +* Inject user_id into request context + +--- + +## 5.2 Agent Authentication (API Keys) + +Agents must be able to: + +* Create account +* Generate API key +* Perform scoped operations + +API keys: + +* Random 32+ byte token +* Only hash stored in DB +* Sent via header: + X-API-Key: + +Scopes example: +{ +"calendars": ["read", "write"], +"events": ["read", "write"], +"contacts": ["read", "write"] +} + +Middleware must validate scope before allowing access. + +--- + +# 6. Data Model Overview + +## Users + +* id (uuid) +* email (unique) +* password_hash +* timezone +* is_active +* created_at +* updated_at +* deleted_at + +## API Keys + +* id (uuid) +* user_id +* name +* key_hash +* scopes (jsonb) +* created_at +* revoked_at + +## Calendars + +* id (uuid) +* owner_id +* name +* color +* is_public +* public_token +* created_at +* updated_at +* deleted_at + +## Calendar Members + +* calendar_id +* user_id +* role (owner/editor/viewer) + +## Events + +* id (uuid) +* calendar_id +* title +* description +* location +* start_time (UTC) +* end_time (UTC) +* timezone +* all_day +* recurrence_rule +* created_by +* updated_by +* created_at +* updated_at +* deleted_at + +## Event Reminders + +* id +* event_id +* minutes_before + +## Event Attendees + +* id +* event_id +* user_id (nullable) +* email (nullable) +* status + +## Contacts + +* id +* owner_id +* first_name +* last_name +* email +* phone +* company +* notes +* created_at +* updated_at +* deleted_at + +--- + +# 7. Full REST Endpoint Specification + +All endpoints return JSON. +All errors return structured error format. + +Error format: +{ +"error": "string", +"code": "string", +"details": "optional" +} + +--- + +# AUTH + +POST /auth/register +POST /auth/login +POST /auth/refresh +POST /auth/logout +GET /auth/me + +--- + +# API KEYS + +POST /api-keys +GET /api-keys +DELETE /api-keys/{id} + +--- + +# USERS + +GET /users/me +PUT /users/me +DELETE /users/me + +--- + +# CALENDARS + +GET /calendars +POST /calendars +GET /calendars/{id} +PUT /calendars/{id} +DELETE /calendars/{id} + +Sharing +POST /calendars/{id}/share +GET /calendars/{id}/members +DELETE /calendars/{id}/members/{user_id} + +--- + +# EVENTS + +GET /events +GET /events/{id} +POST /events +PUT /events/{id} +DELETE /events/{id} + +Filters: +GET /events?start=...&end=... +GET /events?calendar_id=... +GET /events?search=... +GET /events?tag=... + +--- + +# REMINDERS + +POST /events/{id}/reminders +DELETE /events/{id}/reminders/{id} + +--- + +# ATTENDEES + +POST /events/{id}/attendees +PUT /events/{id}/attendees/{id} +DELETE /events/{id}/attendees/{id} + +--- + +# CONTACTS + +GET /contacts +POST /contacts +GET /contacts/{id} +PUT /contacts/{id} +DELETE /contacts/{id} +GET /contacts?search=... + +--- + +# AVAILABILITY + +GET /availability?calendar_id=...&start=...&end=... + +--- + +# BOOKING + +POST /calendars/{id}/booking-link +GET /booking/{token}/availability +POST /booking/{token}/reserve + +--- + +# ICS + +GET /calendars/{id}/export.ics +POST /calendars/import + +--- + +# 8. Index Requirements + +Required indexes: + +* events(calendar_id, start_time) +* events(start_time) +* calendars(owner_id) +* contacts(owner_id) +* api_keys(user_id) + +--- + +# 9. Performance Rules + +* All list endpoints paginated (limit + cursor) +* No N+1 queries +* All joins explicitly defined +* Recurring events expanded lazily +* Reminder queries indexed + +--- + +# 10. Security Requirements + +* Rate limiting middleware +* UUID validation +* Input validation +* SQL injection safe queries +* Password complexity enforcement +* API key hashing + +--- + +# 11. Development Phases + +Phase 1 + +* Auth +* Calendars +* Events +* Contacts + +Phase 2 + +* Recurrence +* Reminders +* Sharing + +Phase 3 + +* Booking links +* Availability +* ICS +* WebSockets + +--- + +This file defines the full system scope. + +The next file (logic.md) will define detailed business rules, validation logic, permission flows, recurrence logic, reminder processing, and booking behavior. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4506e9c --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/calendarapi/internal/api" + "github.com/calendarapi/internal/api/handlers" + "github.com/calendarapi/internal/auth" + "github.com/calendarapi/internal/config" + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/scheduler" + "github.com/calendarapi/internal/service" + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + cfg := config.Load() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("connect to database: %v", err) + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + log.Fatalf("ping database: %v", err) + } + log.Println("connected to PostgreSQL") + + runMigrations(cfg.DatabaseURL) + + queries := repository.New(pool) + + jwtManager := auth.NewJWTManager(cfg.JWTSecret) + + var sched service.ReminderScheduler + if cfg.RedisAddr != "" { + realSched := scheduler.NewScheduler(cfg.RedisAddr) + defer realSched.Close() + sched = realSched + + worker := scheduler.NewReminderWorker(queries) + asynqSrv := scheduler.StartWorker(cfg.RedisAddr, worker) + defer asynqSrv.Shutdown() + + log.Println("Redis connected, background jobs enabled") + } else { + sched = scheduler.NoopScheduler{} + log.Println("Redis not configured, background jobs disabled") + } + + auditSvc := service.NewAuditService(queries) + authSvc := service.NewAuthService(pool, queries, jwtManager, auditSvc) + userSvc := service.NewUserService(pool, queries, auditSvc) + calSvc := service.NewCalendarService(pool, queries, auditSvc) + eventSvc := service.NewEventService(pool, queries, calSvc, auditSvc, sched) + contactSvc := service.NewContactService(queries, auditSvc) + availSvc := service.NewAvailabilityService(queries, calSvc, eventSvc) + bookingSvc := service.NewBookingService(pool, queries, calSvc, eventSvc) + apiKeySvc := service.NewAPIKeyService(queries) + reminderSvc := service.NewReminderService(queries, calSvc, sched) + attendeeSvc := service.NewAttendeeService(queries, calSvc) + + h := api.Handlers{ + Auth: handlers.NewAuthHandler(authSvc, userSvc), + User: handlers.NewUserHandler(userSvc), + Calendar: handlers.NewCalendarHandler(calSvc), + Sharing: handlers.NewSharingHandler(calSvc), + Event: handlers.NewEventHandler(eventSvc), + Reminder: handlers.NewReminderHandler(reminderSvc), + Attendee: handlers.NewAttendeeHandler(attendeeSvc), + Contact: handlers.NewContactHandler(contactSvc), + Availability: handlers.NewAvailabilityHandler(availSvc), + Booking: handlers.NewBookingHandler(bookingSvc), + APIKey: handlers.NewAPIKeyHandler(apiKeySvc), + ICS: handlers.NewICSHandler(calSvc, eventSvc, queries), + } + + authMW := middleware.NewAuthMiddleware(jwtManager, queries) + rateLimiter := middleware.NewRateLimiter(100, 200) + + router := api.NewRouter(h, authMW, rateLimiter) + + srv := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("server starting on port %s (env=%s)", cfg.ServerPort, cfg.Env) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server error: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("shutting down server...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Fatalf("server shutdown error: %v", err) + } + log.Println("server stopped") +} + +func runMigrations(databaseURL string) { + m, err := migrate.New("file://migrations", databaseURL) + if err != nil { + log.Printf("migration init: %v (skipping)", err) + return + } + defer m.Close() + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + log.Fatalf("migration error: %v", err) + } + v, dirty, _ := m.Version() + log.Printf("migrations applied (version=%d, dirty=%v)", v, dirty) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e20107c --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/calendarapi + +go 1.24.0 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/hibiken/asynq v0.26.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/teambition/rrule-go v1.8.2 + golang.org/x/crypto v0.48.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/redis/go-redis/v9 v9.14.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/spf13/cast v1.10.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a495ed --- /dev/null +++ b/go.sum @@ -0,0 +1,123 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= +github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.14.1 h1:nDCrEiJmfOWhD76xlaw+HXT0c9hfNWeXgl0vIRYSDvQ= +github.com/redis/go-redis/v9 v9.14.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers/apikeys.go b/internal/api/handlers/apikeys.go new file mode 100644 index 0000000..71174b7 --- /dev/null +++ b/internal/api/handlers/apikeys.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "net/http" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" +) + +type APIKeyHandler struct { + apiKeySvc *service.APIKeyService +} + +func NewAPIKeyHandler(apiKeySvc *service.APIKeyService) *APIKeyHandler { + return &APIKeyHandler{apiKeySvc: apiKeySvc} +} + +func (h *APIKeyHandler) Create(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var req struct { + Name string `json:"name"` + Scopes map[string][]string `json:"scopes"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + key, err := h.apiKeySvc.Create(r.Context(), userID, req.Name, req.Scopes) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, key) +} + +func (h *APIKeyHandler) List(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + keys, err := h.apiKeySvc.List(r.Context(), userID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteList(w, keys, models.PageInfo{Limit: utils.DefaultLimit}) +} + +func (h *APIKeyHandler) Revoke(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + keyID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + if err := h.apiKeySvc.Revoke(r.Context(), userID, keyID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/handlers/attendees.go b/internal/api/handlers/attendees.go new file mode 100644 index 0000000..ee8f419 --- /dev/null +++ b/internal/api/handlers/attendees.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "net/http" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type AttendeeHandler struct { + attendeeSvc *service.AttendeeService +} + +func NewAttendeeHandler(attendeeSvc *service.AttendeeService) *AttendeeHandler { + return &AttendeeHandler{attendeeSvc: attendeeSvc} +} + +func (h *AttendeeHandler) Add(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req struct { + Attendees []struct { + UserID *uuid.UUID `json:"user_id"` + Email *string `json:"email"` + } `json:"attendees"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + var addReqs []service.AddAttendeeRequest + for _, a := range req.Attendees { + addReqs = append(addReqs, service.AddAttendeeRequest{ + UserID: a.UserID, + Email: a.Email, + }) + } + + event, err := h.attendeeSvc.AddAttendees(r.Context(), userID, eventID, addReqs) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event}) +} + +func (h *AttendeeHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + attendeeID, err := utils.ValidateUUID(chi.URLParam(r, "attendeeID")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req struct { + Status string `json:"status"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + event, err := h.attendeeSvc.UpdateStatus(r.Context(), userID, eventID, attendeeID, req.Status) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event}) +} + +func (h *AttendeeHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + attendeeID, err := utils.ValidateUUID(chi.URLParam(r, "attendeeID")) + if err != nil { + utils.WriteError(w, err) + return + } + + if err := h.attendeeSvc.DeleteAttendee(r.Context(), userID, eventID, attendeeID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 0000000..dd4963c --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "net/http" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" +) + +type AuthHandler struct { + authSvc *service.AuthService + userSvc *service.UserService +} + +func NewAuthHandler(authSvc *service.AuthService, userSvc *service.UserService) *AuthHandler { + return &AuthHandler{authSvc: authSvc, userSvc: userSvc} +} + +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + Timezone string `json:"timezone"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + result, err := h.authSvc.Register(r.Context(), req.Email, req.Password, req.Timezone) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, result) +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + result, err := h.authSvc.Login(r.Context(), req.Email, req.Password) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, result) +} + +func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + result, err := h.authSvc.Refresh(r.Context(), req.RefreshToken) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, result) +} + +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + if err := h.authSvc.Logout(r.Context(), req.RefreshToken); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} + +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + utils.WriteError(w, models.ErrAuthRequired) + return + } + + user, err := h.userSvc.GetMe(r.Context(), userID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user}) +} diff --git a/internal/api/handlers/availability.go b/internal/api/handlers/availability.go new file mode 100644 index 0000000..63d75f1 --- /dev/null +++ b/internal/api/handlers/availability.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" +) + +type AvailabilityHandler struct { + availSvc *service.AvailabilityService +} + +func NewAvailabilityHandler(availSvc *service.AvailabilityService) *AvailabilityHandler { + return &AvailabilityHandler{availSvc: availSvc} +} + +func (h *AvailabilityHandler) Get(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + q := r.URL.Query() + + calIDStr := q.Get("calendar_id") + if calIDStr == "" { + utils.WriteError(w, models.NewValidationError("calendar_id required")) + return + } + calID, err := utils.ValidateUUID(calIDStr) + if err != nil { + utils.WriteError(w, err) + return + } + + startStr := q.Get("start") + endStr := q.Get("end") + if startStr == "" || endStr == "" { + utils.WriteError(w, models.NewValidationError("start and end required")) + return + } + start, err := time.Parse(time.RFC3339, startStr) + if err != nil { + utils.WriteError(w, models.NewValidationError("invalid start time")) + return + } + end, err := time.Parse(time.RFC3339, endStr) + if err != nil { + utils.WriteError(w, models.NewValidationError("invalid end time")) + return + } + + result, err := h.availSvc.GetBusyBlocks(r.Context(), userID, calID, start, end) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, result) +} diff --git a/internal/api/handlers/booking.go b/internal/api/handlers/booking.go new file mode 100644 index 0000000..07292ec --- /dev/null +++ b/internal/api/handlers/booking.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" +) + +type BookingHandler struct { + bookingSvc *service.BookingService +} + +func NewBookingHandler(bookingSvc *service.BookingService) *BookingHandler { + return &BookingHandler{bookingSvc: bookingSvc} +} + +func (h *BookingHandler) CreateLink(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req models.BookingConfig + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + link, err := h.bookingSvc.CreateLink(r.Context(), userID, calID, req) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, link) +} + +func (h *BookingHandler) GetAvailability(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + q := r.URL.Query() + + startStr := q.Get("start") + endStr := q.Get("end") + if startStr == "" || endStr == "" { + utils.WriteError(w, models.NewValidationError("start and end required")) + return + } + + start, err := time.Parse(time.RFC3339, startStr) + if err != nil { + utils.WriteError(w, models.NewValidationError("invalid start time")) + return + } + end, err := time.Parse(time.RFC3339, endStr) + if err != nil { + utils.WriteError(w, models.NewValidationError("invalid end time")) + return + } + + result, err := h.bookingSvc.GetAvailability(r.Context(), token, start, end) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, result) +} + +func (h *BookingHandler) Reserve(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + + var req struct { + Name string `json:"name"` + Email string `json:"email"` + SlotStart time.Time `json:"slot_start"` + SlotEnd time.Time `json:"slot_end"` + Notes *string `json:"notes"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + event, err := h.bookingSvc.Reserve(r.Context(), token, req.Name, req.Email, req.SlotStart, req.SlotEnd, req.Notes) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "event": event}) +} diff --git a/internal/api/handlers/calendars.go b/internal/api/handlers/calendars.go new file mode 100644 index 0000000..88fb7f4 --- /dev/null +++ b/internal/api/handlers/calendars.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "net/http" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" +) + +type CalendarHandler struct { + calSvc *service.CalendarService +} + +func NewCalendarHandler(calSvc *service.CalendarService) *CalendarHandler { + return &CalendarHandler{calSvc: calSvc} +} + +func (h *CalendarHandler) List(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + calendars, err := h.calSvc.List(r.Context(), userID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteList(w, calendars, models.PageInfo{Limit: utils.DefaultLimit}) +} + +func (h *CalendarHandler) Create(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var req struct { + Name string `json:"name"` + Color string `json:"color"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + cal, err := h.calSvc.Create(r.Context(), userID, req.Name, req.Color) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal}) +} + +func (h *CalendarHandler) Get(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + cal, err := h.calSvc.Get(r.Context(), userID, calID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal}) +} + +func (h *CalendarHandler) Update(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req struct { + Name *string `json:"name"` + Color *string `json:"color"` + IsPublic *bool `json:"is_public"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + cal, err := h.calSvc.Update(r.Context(), userID, calID, req.Name, req.Color, req.IsPublic) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"calendar": cal}) +} + +func (h *CalendarHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + if err := h.calSvc.Delete(r.Context(), userID, calID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/handlers/contacts.go b/internal/api/handlers/contacts.go new file mode 100644 index 0000000..7b84bd7 --- /dev/null +++ b/internal/api/handlers/contacts.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" +) + +type ContactHandler struct { + contactSvc *service.ContactService +} + +func NewContactHandler(contactSvc *service.ContactService) *ContactHandler { + return &ContactHandler{contactSvc: contactSvc} +} + +func (h *ContactHandler) List(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + q := r.URL.Query() + + var search *string + if s := q.Get("search"); s != "" { + search = &s + } + limit := 50 + if l := q.Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil { + limit = v + } + } + + contacts, cursor, err := h.contactSvc.List(r.Context(), userID, search, limit, q.Get("cursor")) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteList(w, contacts, models.PageInfo{Limit: int(utils.ClampLimit(limit)), NextCursor: cursor}) +} + +func (h *ContactHandler) Create(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var req struct { + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + Email *string `json:"email"` + Phone *string `json:"phone"` + Company *string `json:"company"` + Notes *string `json:"notes"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + contact, err := h.contactSvc.Create(r.Context(), userID, service.CreateContactRequest{ + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + Phone: req.Phone, + Company: req.Company, + Notes: req.Notes, + }) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact}) +} + +func (h *ContactHandler) Get(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + contactID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + contact, err := h.contactSvc.Get(r.Context(), userID, contactID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact}) +} + +func (h *ContactHandler) Update(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + contactID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req struct { + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + Email *string `json:"email"` + Phone *string `json:"phone"` + Company *string `json:"company"` + Notes *string `json:"notes"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + contact, err := h.contactSvc.Update(r.Context(), userID, contactID, service.UpdateContactRequest{ + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + Phone: req.Phone, + Company: req.Company, + Notes: req.Notes, + }) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"contact": contact}) +} + +func (h *ContactHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + contactID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + if err := h.contactSvc.Delete(r.Context(), userID, contactID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/handlers/events.go b/internal/api/handlers/events.go new file mode 100644 index 0000000..6cec6ed --- /dev/null +++ b/internal/api/handlers/events.go @@ -0,0 +1,204 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type EventHandler struct { + eventSvc *service.EventService +} + +func NewEventHandler(eventSvc *service.EventService) *EventHandler { + return &EventHandler{eventSvc: eventSvc} +} + +func (h *EventHandler) List(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + q := r.URL.Query() + + startStr := q.Get("start") + endStr := q.Get("end") + if startStr == "" || endStr == "" { + utils.WriteError(w, models.NewValidationError("start and end query params required")) + return + } + start, err := time.Parse(time.RFC3339, startStr) + if err != nil { + utils.WriteError(w, models.NewValidationError("invalid start time")) + return + } + end, err := time.Parse(time.RFC3339, endStr) + if err != nil { + utils.WriteError(w, models.NewValidationError("invalid end time")) + return + } + + var calendarID *uuid.UUID + if cid := q.Get("calendar_id"); cid != "" { + id, err := utils.ValidateUUID(cid) + if err != nil { + utils.WriteError(w, err) + return + } + calendarID = &id + } + + var search *string + if s := q.Get("search"); s != "" { + search = &s + } + var tag *string + if t := q.Get("tag"); t != "" { + tag = &t + } + + limit := 50 + if l := q.Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil { + limit = v + } + } + + events, cursor, err := h.eventSvc.List(r.Context(), userID, service.ListEventParams{ + RangeStart: start, + RangeEnd: end, + CalendarID: calendarID, + Search: search, + Tag: tag, + Limit: limit, + Cursor: q.Get("cursor"), + }) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteList(w, events, models.PageInfo{Limit: int(utils.ClampLimit(limit)), NextCursor: cursor}) +} + +func (h *EventHandler) Create(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + var req struct { + CalendarID uuid.UUID `json:"calendar_id"` + Title string `json:"title"` + Description *string `json:"description"` + Location *string `json:"location"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Timezone string `json:"timezone"` + AllDay bool `json:"all_day"` + RecurrenceRule *string `json:"recurrence_rule"` + Reminders []int32 `json:"reminders"` + Tags []string `json:"tags"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + event, err := h.eventSvc.Create(r.Context(), userID, service.CreateEventRequest{ + CalendarID: req.CalendarID, + Title: req.Title, + Description: req.Description, + Location: req.Location, + StartTime: req.StartTime, + EndTime: req.EndTime, + Timezone: req.Timezone, + AllDay: req.AllDay, + RecurrenceRule: req.RecurrenceRule, + Reminders: req.Reminders, + Tags: req.Tags, + }) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event}) +} + +func (h *EventHandler) Get(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + event, err := h.eventSvc.Get(r.Context(), userID, eventID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event}) +} + +func (h *EventHandler) Update(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req struct { + Title *string `json:"title"` + Description *string `json:"description"` + Location *string `json:"location"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + Timezone *string `json:"timezone"` + AllDay *bool `json:"all_day"` + RecurrenceRule *string `json:"recurrence_rule"` + Tags []string `json:"tags"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + event, err := h.eventSvc.Update(r.Context(), userID, eventID, service.UpdateEventRequest{ + Title: req.Title, + Description: req.Description, + Location: req.Location, + StartTime: req.StartTime, + EndTime: req.EndTime, + Timezone: req.Timezone, + AllDay: req.AllDay, + RecurrenceRule: req.RecurrenceRule, + Tags: req.Tags, + }) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event}) +} + +func (h *EventHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + if err := h.eventSvc.Delete(r.Context(), userID, eventID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/handlers/ics.go b/internal/api/handlers/ics.go new file mode 100644 index 0000000..af9b96a --- /dev/null +++ b/internal/api/handlers/ics.go @@ -0,0 +1,189 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type ICSHandler struct { + calSvc *service.CalendarService + eventSvc *service.EventService + queries *repository.Queries +} + +func NewICSHandler(calSvc *service.CalendarService, eventSvc *service.EventService, queries *repository.Queries) *ICSHandler { + return &ICSHandler{calSvc: calSvc, eventSvc: eventSvc, queries: queries} +} + +func (h *ICSHandler) Export(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + if _, err := h.calSvc.GetRole(r.Context(), calID, userID); err != nil { + utils.WriteError(w, err) + return + } + + now := time.Now().UTC() + rangeStart := now.AddDate(-1, 0, 0) + rangeEnd := now.AddDate(1, 0, 0) + + events, err := h.queries.ListEventsByCalendarInRange(r.Context(), repository.ListEventsByCalendarInRangeParams{ + CalendarID: utils.ToPgUUID(calID), + EndTime: utils.ToPgTimestamptz(rangeStart), + StartTime: utils.ToPgTimestamptz(rangeEnd), + }) + if err != nil { + utils.WriteError(w, models.ErrInternal) + return + } + + var b strings.Builder + b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//CalendarAPI//EN\r\n") + + for _, ev := range events { + b.WriteString("BEGIN:VEVENT\r\n") + b.WriteString(fmt.Sprintf("UID:%s\r\n", utils.FromPgUUID(ev.ID).String())) + b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", utils.FromPgTimestamptz(ev.StartTime).Format("20060102T150405Z"))) + b.WriteString(fmt.Sprintf("DTEND:%s\r\n", utils.FromPgTimestamptz(ev.EndTime).Format("20060102T150405Z"))) + b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", ev.Title)) + if ev.Description.Valid { + b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", ev.Description.String)) + } + if ev.Location.Valid { + b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", ev.Location.String)) + } + if ev.RecurrenceRule.Valid { + b.WriteString(fmt.Sprintf("RRULE:%s\r\n", ev.RecurrenceRule.String)) + } + b.WriteString("END:VEVENT\r\n") + } + + b.WriteString("END:VCALENDAR\r\n") + + w.Header().Set("Content-Type", "text/calendar") + w.Header().Set("Content-Disposition", "attachment; filename=calendar.ics") + w.WriteHeader(http.StatusOK) + w.Write([]byte(b.String())) +} + +func (h *ICSHandler) Import(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + + if err := r.ParseMultipartForm(10 << 20); err != nil { + utils.WriteError(w, models.NewValidationError("invalid multipart form")) + return + } + + calIDStr := r.FormValue("calendar_id") + calID, err := utils.ValidateUUID(calIDStr) + if err != nil { + utils.WriteError(w, err) + return + } + + role, err := h.calSvc.GetRole(r.Context(), calID, userID) + if err != nil { + utils.WriteError(w, err) + return + } + if role != "owner" && role != "editor" { + utils.WriteError(w, models.ErrForbidden) + return + } + + file, _, err := r.FormFile("file") + if err != nil { + utils.WriteError(w, models.NewValidationError("file required")) + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + utils.WriteError(w, models.ErrInternal) + return + } + + count := h.parseAndImportICS(r.Context(), string(data), calID, userID) + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "imported": map[string]int{"events": count}, + }) +} + +func (h *ICSHandler) parseAndImportICS(ctx context.Context, data string, calID, userID uuid.UUID) int { + count := 0 + lines := strings.Split(data, "\n") + var inEvent bool + var title, description, location, rruleStr string + var dtstart, dtend time.Time + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "BEGIN:VEVENT" { + inEvent = true + title, description, location, rruleStr = "", "", "", "" + dtstart, dtend = time.Time{}, time.Time{} + continue + } + if line == "END:VEVENT" && inEvent { + inEvent = false + if title != "" && !dtstart.IsZero() && !dtend.IsZero() { + _, err := h.queries.CreateEvent(ctx, repository.CreateEventParams{ + ID: utils.ToPgUUID(uuid.New()), + CalendarID: utils.ToPgUUID(calID), + Title: title, + Description: utils.ToPgText(description), + Location: utils.ToPgText(location), + StartTime: utils.ToPgTimestamptz(dtstart), + EndTime: utils.ToPgTimestamptz(dtend), + Timezone: "UTC", + RecurrenceRule: utils.ToPgText(rruleStr), + Tags: []string{}, + CreatedBy: utils.ToPgUUID(userID), + UpdatedBy: utils.ToPgUUID(userID), + }) + if err == nil { + count++ + } + } + continue + } + if !inEvent { + continue + } + switch { + case strings.HasPrefix(line, "SUMMARY:"): + title = strings.TrimPrefix(line, "SUMMARY:") + case strings.HasPrefix(line, "DESCRIPTION:"): + description = strings.TrimPrefix(line, "DESCRIPTION:") + case strings.HasPrefix(line, "LOCATION:"): + location = strings.TrimPrefix(line, "LOCATION:") + case strings.HasPrefix(line, "RRULE:"): + rruleStr = strings.TrimPrefix(line, "RRULE:") + case strings.HasPrefix(line, "DTSTART:"): + dtstart, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTSTART:")) + case strings.HasPrefix(line, "DTEND:"): + dtend, _ = time.Parse("20060102T150405Z", strings.TrimPrefix(line, "DTEND:")) + } + } + return count +} diff --git a/internal/api/handlers/reminders.go b/internal/api/handlers/reminders.go new file mode 100644 index 0000000..109ceaf --- /dev/null +++ b/internal/api/handlers/reminders.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "net/http" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" +) + +type ReminderHandler struct { + reminderSvc *service.ReminderService +} + +func NewReminderHandler(reminderSvc *service.ReminderService) *ReminderHandler { + return &ReminderHandler{reminderSvc: reminderSvc} +} + +func (h *ReminderHandler) Add(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req struct { + MinutesBefore []int32 `json:"minutes_before"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + event, err := h.reminderSvc.AddReminders(r.Context(), userID, eventID, req.MinutesBefore) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"event": event}) +} + +func (h *ReminderHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + eventID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + reminderID, err := utils.ValidateUUID(chi.URLParam(r, "reminderID")) + if err != nil { + utils.WriteError(w, err) + return + } + + if err := h.reminderSvc.DeleteReminder(r.Context(), userID, eventID, reminderID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/handlers/sharing.go b/internal/api/handlers/sharing.go new file mode 100644 index 0000000..6945033 --- /dev/null +++ b/internal/api/handlers/sharing.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "net/http" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" + "github.com/go-chi/chi/v5" +) + +type SharingHandler struct { + calSvc *service.CalendarService +} + +func NewSharingHandler(calSvc *service.CalendarService) *SharingHandler { + return &SharingHandler{calSvc: calSvc} +} + +func (h *SharingHandler) Share(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + var req struct { + Target struct { + Email string `json:"email"` + } `json:"target"` + Role string `json:"role"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + if err := h.calSvc.Share(r.Context(), userID, calID, req.Target.Email, req.Role); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} + +func (h *SharingHandler) ListMembers(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + + members, err := h.calSvc.ListMembers(r.Context(), userID, calID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteList(w, members, models.PageInfo{Limit: utils.DefaultLimit}) +} + +func (h *SharingHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.GetUserID(r.Context()) + calID, err := utils.ValidateUUID(chi.URLParam(r, "id")) + if err != nil { + utils.WriteError(w, err) + return + } + targetID, err := utils.ValidateUUID(chi.URLParam(r, "userID")) + if err != nil { + utils.WriteError(w, err) + return + } + + if err := h.calSvc.RemoveMember(r.Context(), userID, calID, targetID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/handlers/users.go b/internal/api/handlers/users.go new file mode 100644 index 0000000..ad178c6 --- /dev/null +++ b/internal/api/handlers/users.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "net/http" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/service" + "github.com/calendarapi/internal/utils" +) + +type UserHandler struct { + userSvc *service.UserService +} + +func NewUserHandler(userSvc *service.UserService) *UserHandler { + return &UserHandler{userSvc: userSvc} +} + +func (h *UserHandler) GetMe(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + utils.WriteError(w, models.ErrAuthRequired) + return + } + + user, err := h.userSvc.GetMe(r.Context(), userID) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user}) +} + +func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + utils.WriteError(w, models.ErrAuthRequired) + return + } + + var req struct { + Timezone *string `json:"timezone"` + } + if err := utils.DecodeJSON(r, &req); err != nil { + utils.WriteError(w, err) + return + } + + user, err := h.userSvc.Update(r.Context(), userID, req.Timezone) + if err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteJSON(w, http.StatusOK, map[string]interface{}{"user": user}) +} + +func (h *UserHandler) DeleteMe(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserID(r.Context()) + if !ok { + utils.WriteError(w, models.ErrAuthRequired) + return + } + + if err := h.userSvc.Delete(r.Context(), userID); err != nil { + utils.WriteError(w, err) + return + } + + utils.WriteOK(w) +} diff --git a/internal/api/openapi/openapi.go b/internal/api/openapi/openapi.go new file mode 100644 index 0000000..fefc123 --- /dev/null +++ b/internal/api/openapi/openapi.go @@ -0,0 +1,120 @@ +package openapi + +import ( + "embed" + "encoding/json" + "io/fs" + "log" + "net/http" + "strings" +) + +//go:embed specs/*.json +var specFiles embed.FS + +var mergedSpec []byte + +func init() { + spec, err := buildSpec() + if err != nil { + log.Fatalf("openapi: build spec: %v", err) + } + mergedSpec = spec +} + +func buildSpec() ([]byte, error) { + base, err := loadJSON("specs/base.json") + if err != nil { + return nil, err + } + + schemas, err := loadJSON("specs/schemas.json") + if err != nil { + return nil, err + } + if comps, ok := schemas["components"]; ok { + base["components"] = comps + } + + allPaths := make(map[string]interface{}) + + entries, err := fs.ReadDir(specFiles, "specs") + if err != nil { + return nil, err + } + + for _, entry := range entries { + name := entry.Name() + if name == "base.json" || name == "schemas.json" || !strings.HasSuffix(name, ".json") { + continue + } + partial, err := loadJSON("specs/" + name) + if err != nil { + return nil, err + } + if paths, ok := partial["paths"].(map[string]interface{}); ok { + for path, ops := range paths { + allPaths[path] = ops + } + } + } + + base["paths"] = allPaths + + return json.MarshalIndent(base, "", " ") +} + +func loadJSON(path string) (map[string]interface{}, error) { + data, err := specFiles.ReadFile(path) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func SpecHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(mergedSpec) +} + +const swaggerHTML = ` + + + + + Calendar & Contacts API – Docs + + + + +
+ + + +` + +func DocsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(swaggerHTML)) +} diff --git a/internal/api/openapi/specs/apikeys.json b/internal/api/openapi/specs/apikeys.json new file mode 100644 index 0000000..283118d --- /dev/null +++ b/internal/api/openapi/specs/apikeys.json @@ -0,0 +1,101 @@ +{ + "paths": { + "/api-keys": { + "post": { + "tags": ["API Keys"], + "summary": "Create a new API key", + "description": "Creates a new API key with specified scopes for agent/programmatic access. The raw token is returned only once in the response.", + "operationId": "createApiKey", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "scopes"], + "properties": { + "name": { "type": "string", "example": "My agent key" }, + "scopes": { + "type": "object", + "description": "Permission scopes for the API key", + "properties": { + "calendars": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } }, + "events": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } }, + "contacts": { "type": "array", "items": { "type": "string", "enum": ["read", "write"] } }, + "availability": { "type": "array", "items": { "type": "string", "enum": ["read"] } }, + "booking": { "type": "array", "items": { "type": "string", "enum": ["write"] } } + }, + "example": { + "calendars": ["read", "write"], + "events": ["read", "write"], + "contacts": ["read"], + "availability": ["read"] + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "API key created (token shown only once)", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/APIKeyResponse" } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "get": { + "tags": ["API Keys"], + "summary": "List API keys", + "description": "Returns all API keys for the authenticated user. Tokens are never returned in list responses.", + "operationId": "listApiKeys", + "responses": { + "200": { + "description": "List of API keys", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items", "page"], + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/APIKeyResponse" } }, + "page": { "$ref": "#/components/schemas/PageInfo" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/api-keys/{id}": { + "delete": { + "tags": ["API Keys"], + "summary": "Revoke an API key", + "description": "Revokes the specified API key, preventing further use.", + "operationId": "revokeApiKey", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "API key ID" + } + ], + "responses": { + "200": { "description": "API key revoked", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "API key not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/auth.json b/internal/api/openapi/specs/auth.json new file mode 100644 index 0000000..6de645f --- /dev/null +++ b/internal/api/openapi/specs/auth.json @@ -0,0 +1,183 @@ +{ + "paths": { + "/auth/register": { + "post": { + "tags": ["Auth"], + "summary": "Register a new user", + "description": "Creates a new user account with email and password. A default calendar is automatically created. Returns the user profile along with access and refresh tokens.", + "operationId": "registerUser", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email", "example": "user@example.com" }, + "password": { "type": "string", "minLength": 10, "example": "securepassword123" }, + "timezone": { "type": "string", "example": "America/Asuncion", "description": "IANA timezone name, defaults to UTC" } + } + } + } + } + }, + "responses": { + "200": { + "description": "User registered successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user", "access_token", "refresh_token"], + "properties": { + "user": { "$ref": "#/components/schemas/User" }, + "access_token": { "type": "string" }, + "refresh_token": { "type": "string" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "409": { "description": "Email already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/auth/login": { + "post": { + "tags": ["Auth"], + "summary": "Login with credentials", + "description": "Authenticates a user with email and password. Returns the user profile along with access and refresh tokens.", + "operationId": "loginUser", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email", "example": "user@example.com" }, + "password": { "type": "string", "example": "securepassword123" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user", "access_token", "refresh_token"], + "properties": { + "user": { "$ref": "#/components/schemas/User" }, + "access_token": { "type": "string" }, + "refresh_token": { "type": "string" } + } + } + } + } + }, + "401": { "description": "Invalid credentials", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/auth/refresh": { + "post": { + "tags": ["Auth"], + "summary": "Refresh access token", + "description": "Exchanges a valid refresh token for a new access/refresh token pair.", + "operationId": "refreshToken", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["refresh_token"], + "properties": { + "refresh_token": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Tokens refreshed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["access_token", "refresh_token"], + "properties": { + "access_token": { "type": "string" }, + "refresh_token": { "type": "string" } + } + } + } + } + }, + "401": { "description": "Invalid or expired refresh token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/auth/logout": { + "post": { + "tags": ["Auth"], + "summary": "Logout and revoke refresh token", + "description": "Revokes the provided refresh token, ending the session.", + "operationId": "logoutUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["refresh_token"], + "properties": { + "refresh_token": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { "description": "Logged out", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } + } + } + }, + "/auth/me": { + "get": { + "tags": ["Auth"], + "summary": "Get current authenticated user", + "description": "Returns the profile of the currently authenticated user.", + "operationId": "getCurrentUser", + "responses": { + "200": { + "description": "Current user", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user"], + "properties": { + "user": { "$ref": "#/components/schemas/User" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/availability.json b/internal/api/openapi/specs/availability.json new file mode 100644 index 0000000..5828110 --- /dev/null +++ b/internal/api/openapi/specs/availability.json @@ -0,0 +1,42 @@ +{ + "paths": { + "/availability": { + "get": { + "tags": ["Availability"], + "summary": "Get calendar availability", + "description": "Returns busy time blocks for a calendar within a given range. Includes expanded recurring event occurrences. User must have at least viewer role on the calendar. Requires `availability:read` scope.", + "operationId": "getAvailability", + "parameters": [ + { "name": "calendar_id", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Calendar to query" }, + { "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" }, + { "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" } + ], + "responses": { + "200": { + "description": "Availability with busy blocks", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["calendar_id", "range_start", "range_end", "busy"], + "properties": { + "calendar_id": { "type": "string", "format": "uuid" }, + "range_start": { "type": "string", "format": "date-time" }, + "range_end": { "type": "string", "format": "date-time" }, + "busy": { + "type": "array", + "items": { "$ref": "#/components/schemas/BusyBlock" } + } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/base.json b/internal/api/openapi/specs/base.json new file mode 100644 index 0000000..d88f575 --- /dev/null +++ b/internal/api/openapi/specs/base.json @@ -0,0 +1,41 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Calendar & Contacts API", + "description": "Production-grade Calendar and Contacts REST API supporting human users, AI agents, and programmatic automation. Features JWT and API key authentication, calendar sharing, recurring events, booking links, ICS import/export, and background reminder processing.", + "version": "1.0.0", + "contact": { + "name": "API Support" + }, + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Local development" + }, + { + "url": "https://api.example.com", + "description": "Production" + } + ], + "tags": [ + { "name": "Auth", "description": "Authentication and session management" }, + { "name": "Users", "description": "User profile management" }, + { "name": "API Keys", "description": "API key management for agents" }, + { "name": "Calendars", "description": "Calendar CRUD and sharing" }, + { "name": "Events", "description": "Event CRUD with recurrence support" }, + { "name": "Reminders", "description": "Event reminder management" }, + { "name": "Attendees", "description": "Event attendee management" }, + { "name": "Contacts", "description": "Contact management" }, + { "name": "Availability", "description": "Calendar availability queries" }, + { "name": "Booking", "description": "Public booking links and reservations" }, + { "name": "ICS", "description": "ICS calendar import and export" } + ], + "security": [ + { "BearerAuth": [] }, + { "ApiKeyAuth": [] } + ] +} diff --git a/internal/api/openapi/specs/booking.json b/internal/api/openapi/specs/booking.json new file mode 100644 index 0000000..312cb00 --- /dev/null +++ b/internal/api/openapi/specs/booking.json @@ -0,0 +1,191 @@ +{ + "paths": { + "/calendars/{id}/booking-link": { + "post": { + "tags": ["Booking"], + "summary": "Create a booking link", + "description": "Creates a public booking link for a calendar with configurable duration, buffer time, working hours, and timezone. Only the calendar owner can create booking links. Requires `booking:write` scope.", + "operationId": "createBookingLink", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["duration_minutes", "timezone", "working_hours"], + "properties": { + "duration_minutes": { "type": "integer", "minimum": 5, "example": 30 }, + "buffer_minutes": { "type": "integer", "minimum": 0, "default": 0, "example": 0 }, + "timezone": { "type": "string", "example": "America/Asuncion" }, + "working_hours": { + "type": "object", + "description": "Working hour windows per day of week", + "properties": { + "mon": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }, + "tue": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }, + "wed": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }, + "thu": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }, + "fri": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }, + "sat": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } }, + "sun": { "type": "array", "items": { "$ref": "#/components/schemas/WorkingHourSlot" } } + }, + "example": { + "mon": [{ "start": "09:00", "end": "17:00" }], + "tue": [{ "start": "09:00", "end": "17:00" }], + "wed": [{ "start": "09:00", "end": "17:00" }], + "thu": [{ "start": "09:00", "end": "17:00" }], + "fri": [{ "start": "09:00", "end": "17:00" }], + "sat": [], + "sun": [] + } + }, + "active": { "type": "boolean", "default": true } + } + } + } + } + }, + "responses": { + "200": { + "description": "Booking link created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["token", "settings"], + "properties": { + "token": { "type": "string" }, + "public_url": { "type": "string", "format": "uri", "example": "https://app.example.com/booking/abc123" }, + "settings": { + "type": "object", + "properties": { + "duration_minutes": { "type": "integer" }, + "buffer_minutes": { "type": "integer" }, + "timezone": { "type": "string" }, + "working_hours": { "type": "object" }, + "active": { "type": "boolean" } + } + } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Only owner can create booking links", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/booking/{token}/availability": { + "get": { + "tags": ["Booking"], + "summary": "Get public booking availability", + "description": "Returns available time slots for a public booking link within a date range. No authentication required. Computes available slots by subtracting busy blocks and applying buffer time to the working hour windows.", + "operationId": "getBookingAvailability", + "security": [], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Booking link token" + }, + { "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" }, + { "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" } + ], + "responses": { + "200": { + "description": "Available booking slots", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["token", "timezone", "duration_minutes", "slots"], + "properties": { + "token": { "type": "string" }, + "timezone": { "type": "string" }, + "duration_minutes": { "type": "integer" }, + "slots": { + "type": "array", + "items": { "$ref": "#/components/schemas/TimeSlot" } + } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Booking link not found or inactive", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/booking/{token}/reserve": { + "post": { + "tags": ["Booking"], + "summary": "Reserve a booking slot", + "description": "Reserves a time slot on a public booking link. Creates an event on the calendar. Uses a database transaction with row locking to prevent double-booking. No authentication required.", + "operationId": "reserveBookingSlot", + "security": [], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Booking link token" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "email", "slot_start", "slot_end"], + "properties": { + "name": { "type": "string", "example": "Visitor Name" }, + "email": { "type": "string", "format": "email", "example": "visitor@example.com" }, + "slot_start": { "type": "string", "format": "date-time" }, + "slot_end": { "type": "string", "format": "date-time" }, + "notes": { "type": "string", "example": "Looking forward to the meeting" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Booking confirmed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["ok", "event"], + "properties": { + "ok": { "type": "boolean", "example": true }, + "event": { "$ref": "#/components/schemas/Event" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Booking link not found or inactive", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "409": { "description": "Slot no longer available (conflict)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/calendars.json b/internal/api/openapi/specs/calendars.json new file mode 100644 index 0000000..c3ef446 --- /dev/null +++ b/internal/api/openapi/specs/calendars.json @@ -0,0 +1,291 @@ +{ + "paths": { + "/calendars": { + "get": { + "tags": ["Calendars"], + "summary": "List calendars", + "description": "Returns all calendars the user owns or has been shared with. Each calendar includes the user's role (owner, editor, or viewer). Requires `calendars:read` scope.", + "operationId": "listCalendars", + "responses": { + "200": { + "description": "List of calendars", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items", "page"], + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/Calendar" } }, + "page": { "$ref": "#/components/schemas/PageInfo" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "post": { + "tags": ["Calendars"], + "summary": "Create a calendar", + "description": "Creates a new calendar owned by the authenticated user. Requires `calendars:write` scope.", + "operationId": "createCalendar", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 80, "example": "Work" }, + "color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Calendar created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["calendar"], + "properties": { + "calendar": { "$ref": "#/components/schemas/Calendar" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/calendars/{id}": { + "get": { + "tags": ["Calendars"], + "summary": "Get a calendar", + "description": "Returns a single calendar by ID. User must be owner or member. Requires `calendars:read` scope.", + "operationId": "getCalendar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + } + ], + "responses": { + "200": { + "description": "Calendar details", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["calendar"], + "properties": { + "calendar": { "$ref": "#/components/schemas/Calendar" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "put": { + "tags": ["Calendars"], + "summary": "Update a calendar", + "description": "Updates a calendar's name, color, or public status. Only the owner can change `is_public`. Requires `calendars:write` scope.", + "operationId": "updateCalendar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 80, "example": "Work Calendar" }, + "color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" }, + "is_public": { "type": "boolean" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Calendar updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["calendar"], + "properties": { + "calendar": { "$ref": "#/components/schemas/Calendar" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "delete": { + "tags": ["Calendars"], + "summary": "Delete a calendar", + "description": "Soft-deletes a calendar and all its events. Only the owner can delete. Requires `calendars:write` scope.", + "operationId": "deleteCalendar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + } + ], + "responses": { + "200": { "description": "Calendar deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Only owner can delete", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/calendars/{id}/share": { + "post": { + "tags": ["Calendars"], + "summary": "Share a calendar", + "description": "Shares a calendar with another user by email, granting them a role (editor or viewer). Only the owner can share. Cannot share with self. Requires `calendars:write` scope.", + "operationId": "shareCalendar", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["target", "role"], + "properties": { + "target": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string", "format": "email", "example": "other@example.com" } + } + }, + "role": { "type": "string", "enum": ["editor", "viewer"], "example": "editor" } + } + } + } + } + }, + "responses": { + "200": { "description": "Calendar shared", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "400": { "description": "Validation error (e.g. sharing with self)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Only owner can share", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar or target user not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/calendars/{id}/members": { + "get": { + "tags": ["Calendars"], + "summary": "List calendar members", + "description": "Returns all members of a calendar with their roles. Requires `calendars:read` scope.", + "operationId": "listCalendarMembers", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + } + ], + "responses": { + "200": { + "description": "List of members", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items", "page"], + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/CalendarMember" } }, + "page": { "$ref": "#/components/schemas/PageInfo" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/calendars/{id}/members/{userID}": { + "delete": { + "tags": ["Calendars"], + "summary": "Remove a calendar member", + "description": "Removes a member from a shared calendar. Only the owner can remove members. The owner cannot be removed. Requires `calendars:write` scope.", + "operationId": "removeCalendarMember", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + }, + { + "name": "userID", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "User ID of the member to remove" + } + ], + "responses": { + "200": { "description": "Member removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "400": { "description": "Cannot remove owner", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Only owner can remove members", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar or member not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/contacts.json b/internal/api/openapi/specs/contacts.json new file mode 100644 index 0000000..12a6e08 --- /dev/null +++ b/internal/api/openapi/specs/contacts.json @@ -0,0 +1,189 @@ +{ + "paths": { + "/contacts": { + "get": { + "tags": ["Contacts"], + "summary": "List contacts", + "description": "Returns the authenticated user's contacts. Supports search (case-insensitive match on first_name, last_name, email, company) and cursor-based pagination. Requires `contacts:read` scope.", + "operationId": "listContacts", + "parameters": [ + { "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Search term for name, email, or company" }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, "description": "Page size" }, + { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor" } + ], + "responses": { + "200": { + "description": "List of contacts", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items", "page"], + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/Contact" } }, + "page": { "$ref": "#/components/schemas/PageInfo" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "post": { + "tags": ["Contacts"], + "summary": "Create a contact", + "description": "Creates a new contact for the authenticated user. At least one identifying field (first_name, last_name, email, or phone) must be provided. Requires `contacts:write` scope.", + "operationId": "createContact", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "first_name": { "type": "string", "example": "Jane" }, + "last_name": { "type": "string", "example": "Doe" }, + "email": { "type": "string", "format": "email", "example": "jane@example.com" }, + "phone": { "type": "string", "example": "+595981000000" }, + "company": { "type": "string", "example": "Example SA" }, + "notes": { "type": "string", "example": "Met at event" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Contact created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["contact"], + "properties": { + "contact": { "$ref": "#/components/schemas/Contact" } + } + } + } + } + }, + "400": { "description": "Validation error (e.g. no identifying field)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/contacts/{id}": { + "get": { + "tags": ["Contacts"], + "summary": "Get a contact", + "description": "Returns a single contact by ID. Only the owner can access their contacts. Requires `contacts:read` scope.", + "operationId": "getContact", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Contact ID" + } + ], + "responses": { + "200": { + "description": "Contact details", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["contact"], + "properties": { + "contact": { "$ref": "#/components/schemas/Contact" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "put": { + "tags": ["Contacts"], + "summary": "Update a contact", + "description": "Updates a contact's fields. Only the owner can update their contacts. Requires `contacts:write` scope.", + "operationId": "updateContact", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Contact ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "first_name": { "type": "string" }, + "last_name": { "type": "string" }, + "email": { "type": "string", "format": "email" }, + "phone": { "type": "string" }, + "company": { "type": "string" }, + "notes": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Contact updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["contact"], + "properties": { + "contact": { "$ref": "#/components/schemas/Contact" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "delete": { + "tags": ["Contacts"], + "summary": "Delete a contact", + "description": "Soft-deletes a contact. Only the owner can delete their contacts. Requires `contacts:write` scope.", + "operationId": "deleteContact", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Contact ID" + } + ], + "responses": { + "200": { "description": "Contact deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Contact not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/events.json b/internal/api/openapi/specs/events.json new file mode 100644 index 0000000..0d03ba9 --- /dev/null +++ b/internal/api/openapi/specs/events.json @@ -0,0 +1,438 @@ +{ + "paths": { + "/events": { + "get": { + "tags": ["Events"], + "summary": "List events", + "description": "Returns events within a time range across all accessible calendars. Recurring events are expanded into individual occurrences within the requested range. Supports filtering by calendar, search text, and tags. Uses cursor-based pagination. Requires `events:read` scope.", + "operationId": "listEvents", + "parameters": [ + { "name": "start", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range start (RFC3339)" }, + { "name": "end", "in": "query", "required": true, "schema": { "type": "string", "format": "date-time" }, "description": "Range end (RFC3339)" }, + { "name": "calendar_id", "in": "query", "schema": { "type": "string", "format": "uuid" }, "description": "Filter by calendar" }, + { "name": "search", "in": "query", "schema": { "type": "string" }, "description": "Full-text search on title/description" }, + { "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "Filter by tag" }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }, "description": "Page size" }, + { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Pagination cursor from previous response" } + ], + "responses": { + "200": { + "description": "List of events (including expanded recurrence occurrences)", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items", "page"], + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/Event" } }, + "page": { "$ref": "#/components/schemas/PageInfo" } + } + } + } + } + }, + "400": { "description": "Validation error (e.g. missing start/end, range too large)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "post": { + "tags": ["Events"], + "summary": "Create an event", + "description": "Creates a new event on the specified calendar. Times are converted to UTC for storage. Supports recurrence rules (RFC5545 RRULE), reminders, and tags. User must have editor or owner role on the calendar. Requires `events:write` scope.", + "operationId": "createEvent", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["calendar_id", "title", "start_time", "end_time", "timezone"], + "properties": { + "calendar_id": { "type": "string", "format": "uuid" }, + "title": { "type": "string", "minLength": 1, "maxLength": 140, "example": "Meeting" }, + "description": { "type": "string", "example": "Project sync" }, + "location": { "type": "string", "example": "Zoom" }, + "start_time": { "type": "string", "format": "date-time", "example": "2026-03-01T14:00:00-03:00" }, + "end_time": { "type": "string", "format": "date-time", "example": "2026-03-01T15:00:00-03:00" }, + "timezone": { "type": "string", "example": "America/Asuncion" }, + "all_day": { "type": "boolean", "default": false }, + "recurrence_rule": { "type": "string", "nullable": true, "example": "FREQ=WEEKLY;BYDAY=MO,WE,FR" }, + "reminders": { "type": "array", "items": { "type": "integer", "minimum": 0, "maximum": 10080 }, "description": "Minutes before event to remind", "example": [10, 60] }, + "tags": { "type": "array", "items": { "type": "string" }, "example": ["work", "sync"] } + } + } + } + } + }, + "responses": { + "200": { + "description": "Event created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { "$ref": "#/components/schemas/Event" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or calendar permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/events/{id}": { + "get": { + "tags": ["Events"], + "summary": "Get an event", + "description": "Returns a single event by ID with all related data (reminders, attendees, tags, attachments). Requires `events:read` scope.", + "operationId": "getEvent", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + } + ], + "responses": { + "200": { + "description": "Event details", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { "$ref": "#/components/schemas/Event" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "put": { + "tags": ["Events"], + "summary": "Update an event", + "description": "Updates an existing event. Times are re-validated and converted to UTC. If recurrence rule changes, it is re-validated. Reminders are rescheduled. Requires `events:write` scope and editor/owner role.", + "operationId": "updateEvent", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 140 }, + "description": { "type": "string", "nullable": true }, + "location": { "type": "string", "nullable": true }, + "start_time": { "type": "string", "format": "date-time" }, + "end_time": { "type": "string", "format": "date-time" }, + "timezone": { "type": "string" }, + "all_day": { "type": "boolean" }, + "recurrence_rule": { "type": "string", "nullable": true }, + "tags": { "type": "array", "items": { "type": "string" } } + } + } + } + } + }, + "responses": { + "200": { + "description": "Event updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { "$ref": "#/components/schemas/Event" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "delete": { + "tags": ["Events"], + "summary": "Delete an event", + "description": "Soft-deletes an event. Requires `events:write` scope and editor/owner role on the calendar.", + "operationId": "deleteEvent", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + } + ], + "responses": { + "200": { "description": "Event deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/events/{id}/reminders": { + "post": { + "tags": ["Reminders"], + "summary": "Add reminders to an event", + "description": "Adds one or more reminders to an event, specified as minutes before the event start. Background jobs are scheduled for each reminder. Requires `events:write` scope.", + "operationId": "addReminders", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["minutes_before"], + "properties": { + "minutes_before": { + "type": "array", + "items": { "type": "integer", "minimum": 0, "maximum": 10080 }, + "example": [5, 15, 60] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Reminders added, returns updated event", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { "$ref": "#/components/schemas/Event" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/events/{id}/reminders/{reminderID}": { + "delete": { + "tags": ["Reminders"], + "summary": "Delete a reminder", + "description": "Removes a specific reminder from an event. Requires `events:write` scope.", + "operationId": "deleteReminder", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + }, + { + "name": "reminderID", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Reminder ID" + } + ], + "responses": { + "200": { "description": "Reminder deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event or reminder not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/events/{id}/attendees": { + "post": { + "tags": ["Attendees"], + "summary": "Add attendees to an event", + "description": "Adds one or more attendees to an event, identified by email or user ID. Initial status is `pending`. Requires `events:write` scope.", + "operationId": "addAttendees", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["attendees"], + "properties": { + "attendees": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { "type": "string", "format": "email" }, + "user_id": { "type": "string", "format": "uuid" } + } + }, + "example": [ + { "email": "guest@example.com" }, + { "user_id": "550e8400-e29b-41d4-a716-446655440000" } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Attendees added, returns updated event", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { "$ref": "#/components/schemas/Event" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/events/{id}/attendees/{attendeeID}": { + "put": { + "tags": ["Attendees"], + "summary": "Update attendee status", + "description": "Updates an attendee's RSVP status. The event organizer can update any attendee; attendees can update their own status. Requires `events:write` scope.", + "operationId": "updateAttendeeStatus", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + }, + { + "name": "attendeeID", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Attendee ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["status"], + "properties": { + "status": { "type": "string", "enum": ["accepted", "declined", "tentative"] } + } + } + } + } + }, + "responses": { + "200": { + "description": "Attendee updated, returns updated event", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { "$ref": "#/components/schemas/Event" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event or attendee not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "delete": { + "tags": ["Attendees"], + "summary": "Remove an attendee", + "description": "Removes an attendee from an event. Requires `events:write` scope.", + "operationId": "deleteAttendee", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Event ID" + }, + { + "name": "attendeeID", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Attendee ID" + } + ], + "responses": { + "200": { "description": "Attendee removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Event or attendee not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/ics.json b/internal/api/openapi/specs/ics.json new file mode 100644 index 0000000..03c77a2 --- /dev/null +++ b/internal/api/openapi/specs/ics.json @@ -0,0 +1,82 @@ +{ + "paths": { + "/calendars/{id}/export.ics": { + "get": { + "tags": ["ICS"], + "summary": "Export calendar as ICS", + "description": "Exports all events from a calendar in ICS (iCalendar) format. Requires `calendars:read` scope.", + "operationId": "exportCalendarICS", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string", "format": "uuid" }, + "description": "Calendar ID" + } + ], + "responses": { + "200": { + "description": "ICS calendar file", + "content": { + "text/calendar": { + "schema": { "type": "string" } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "404": { "description": "Calendar not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/calendars/import": { + "post": { + "tags": ["ICS"], + "summary": "Import an ICS file", + "description": "Imports events from an ICS file into a specified calendar. The file is sent as multipart form data. Requires `calendars:write` scope.", + "operationId": "importCalendarICS", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["calendar_id", "file"], + "properties": { + "calendar_id": { "type": "string", "format": "uuid", "description": "Target calendar ID" }, + "file": { "type": "string", "format": "binary", "description": "ICS file to import" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Import successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["ok", "imported"], + "properties": { + "ok": { "type": "boolean", "example": true }, + "imported": { + "type": "object", + "properties": { + "events": { "type": "integer", "example": 12 } + } + } + } + } + } + } + }, + "400": { "description": "Validation error or invalid ICS", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "403": { "description": "Insufficient scope or permission", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/openapi/specs/schemas.json b/internal/api/openapi/specs/schemas.json new file mode 100644 index 0000000..5e66225 --- /dev/null +++ b/internal/api/openapi/specs/schemas.json @@ -0,0 +1,181 @@ +{ + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT access token obtained from /auth/login or /auth/register" + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key token for agent/programmatic access with scoped permissions" + } + }, + "schemas": { + "Error": { + "type": "object", + "required": ["error", "code"], + "properties": { + "error": { "type": "string", "description": "Human-readable error message" }, + "code": { "type": "string", "description": "Machine-readable error code", "enum": ["VALIDATION_ERROR", "AUTH_REQUIRED", "AUTH_INVALID", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "RATE_LIMITED", "INTERNAL"] }, + "details": { "description": "Additional error context" } + } + }, + "OkResponse": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "type": "boolean", "example": true } + } + }, + "PageInfo": { + "type": "object", + "required": ["limit", "next_cursor"], + "properties": { + "limit": { "type": "integer", "example": 50 }, + "next_cursor": { "type": "string", "nullable": true, "description": "Opaque cursor for next page, null if no more results" } + } + }, + "User": { + "type": "object", + "required": ["id", "email", "timezone", "created_at", "updated_at"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "email": { "type": "string", "format": "email" }, + "timezone": { "type": "string", "example": "America/New_York", "description": "IANA timezone name" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + } + }, + "Calendar": { + "type": "object", + "required": ["id", "name", "color", "is_public", "created_at", "updated_at"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string", "minLength": 1, "maxLength": 80 }, + "color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#22C55E" }, + "is_public": { "type": "boolean" }, + "role": { "type": "string", "enum": ["owner", "editor", "viewer"], "description": "Current user's role on this calendar" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + } + }, + "Reminder": { + "type": "object", + "required": ["id", "minutes_before"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "minutes_before": { "type": "integer", "minimum": 0, "maximum": 10080 } + } + }, + "Attendee": { + "type": "object", + "required": ["id", "status"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "user_id": { "type": "string", "format": "uuid", "nullable": true }, + "email": { "type": "string", "format": "email", "nullable": true }, + "status": { "type": "string", "enum": ["pending", "accepted", "declined", "tentative"] } + } + }, + "Attachment": { + "type": "object", + "required": ["id", "file_url"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "file_url": { "type": "string", "format": "uri" } + } + }, + "Event": { + "type": "object", + "required": ["id", "calendar_id", "title", "start_time", "end_time", "timezone", "all_day", "created_by", "updated_by", "created_at", "updated_at"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "calendar_id": { "type": "string", "format": "uuid" }, + "title": { "type": "string", "minLength": 1, "maxLength": 140 }, + "description": { "type": "string", "nullable": true }, + "location": { "type": "string", "nullable": true }, + "start_time": { "type": "string", "format": "date-time", "description": "UTC start time in RFC3339" }, + "end_time": { "type": "string", "format": "date-time", "description": "UTC end time in RFC3339" }, + "timezone": { "type": "string", "example": "America/Asuncion", "description": "Original IANA timezone" }, + "all_day": { "type": "boolean" }, + "recurrence_rule": { "type": "string", "nullable": true, "description": "RFC5545 RRULE string", "example": "FREQ=WEEKLY;BYDAY=MO,WE,FR" }, + "is_occurrence": { "type": "boolean", "description": "True if this is an expanded recurrence occurrence" }, + "occurrence_start_time": { "type": "string", "format": "date-time", "nullable": true }, + "occurrence_end_time": { "type": "string", "format": "date-time", "nullable": true }, + "created_by": { "type": "string", "format": "uuid" }, + "updated_by": { "type": "string", "format": "uuid" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" }, + "reminders": { "type": "array", "items": { "$ref": "#/components/schemas/Reminder" } }, + "attendees": { "type": "array", "items": { "$ref": "#/components/schemas/Attendee" } }, + "tags": { "type": "array", "items": { "type": "string" } }, + "attachments": { "type": "array", "items": { "$ref": "#/components/schemas/Attachment" } } + } + }, + "Contact": { + "type": "object", + "required": ["id", "created_at", "updated_at"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "first_name": { "type": "string", "nullable": true }, + "last_name": { "type": "string", "nullable": true }, + "email": { "type": "string", "format": "email", "nullable": true }, + "phone": { "type": "string", "nullable": true }, + "company": { "type": "string", "nullable": true }, + "notes": { "type": "string", "nullable": true }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + } + }, + "APIKeyResponse": { + "type": "object", + "required": ["id", "name", "created_at"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" }, + "revoked_at": { "type": "string", "format": "date-time", "nullable": true }, + "token": { "type": "string", "description": "Raw token, only returned once on creation" } + } + }, + "CalendarMember": { + "type": "object", + "required": ["user_id", "email", "role"], + "properties": { + "user_id": { "type": "string", "format": "uuid" }, + "email": { "type": "string", "format": "email" }, + "role": { "type": "string", "enum": ["owner", "editor", "viewer"] } + } + }, + "BusyBlock": { + "type": "object", + "required": ["start", "end", "event_id"], + "properties": { + "start": { "type": "string", "format": "date-time" }, + "end": { "type": "string", "format": "date-time" }, + "event_id": { "type": "string", "format": "uuid" } + } + }, + "WorkingHourSlot": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { "type": "string", "example": "09:00", "pattern": "^\\d{2}:\\d{2}$" }, + "end": { "type": "string", "example": "17:00", "pattern": "^\\d{2}:\\d{2}$" } + } + }, + "TimeSlot": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { "type": "string", "format": "date-time" }, + "end": { "type": "string", "format": "date-time" } + } + } + } + } +} diff --git a/internal/api/openapi/specs/users.json b/internal/api/openapi/specs/users.json new file mode 100644 index 0000000..402e83f --- /dev/null +++ b/internal/api/openapi/specs/users.json @@ -0,0 +1,76 @@ +{ + "paths": { + "/users/me": { + "get": { + "tags": ["Users"], + "summary": "Get current user profile", + "description": "Returns the full profile of the authenticated user.", + "operationId": "getUserProfile", + "responses": { + "200": { + "description": "User profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user"], + "properties": { + "user": { "$ref": "#/components/schemas/User" } + } + } + } + } + }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "put": { + "tags": ["Users"], + "summary": "Update current user profile", + "description": "Updates the authenticated user's profile fields such as timezone.", + "operationId": "updateUserProfile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timezone": { "type": "string", "example": "America/Asuncion", "description": "IANA timezone name" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated user", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["user"], + "properties": { + "user": { "$ref": "#/components/schemas/User" } + } + } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + }, + "delete": { + "tags": ["Users"], + "summary": "Delete current user (soft delete)", + "description": "Soft-deletes the authenticated user account along with all associated calendars, events, contacts, and revokes all API keys.", + "operationId": "deleteUser", + "responses": { + "200": { "description": "User deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, + "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + } + } +} diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..98dbba6 --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,128 @@ +package api + +import ( + "github.com/calendarapi/internal/api/handlers" + "github.com/calendarapi/internal/api/openapi" + mw "github.com/calendarapi/internal/middleware" + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" +) + +type Handlers struct { + Auth *handlers.AuthHandler + User *handlers.UserHandler + Calendar *handlers.CalendarHandler + Sharing *handlers.SharingHandler + Event *handlers.EventHandler + Reminder *handlers.ReminderHandler + Attendee *handlers.AttendeeHandler + Contact *handlers.ContactHandler + Availability *handlers.AvailabilityHandler + Booking *handlers.BookingHandler + APIKey *handlers.APIKeyHandler + ICS *handlers.ICSHandler +} + +func NewRouter(h Handlers, authMW *mw.AuthMiddleware, rateLimiter *mw.RateLimiter) *chi.Mux { + r := chi.NewRouter() + + r.Use(chimw.Logger) + r.Use(chimw.Recoverer) + r.Use(chimw.RealIP) + r.Use(rateLimiter.Limit) + + // OpenAPI spec and Swagger UI + r.Get("/openapi.json", openapi.SpecHandler) + r.Get("/docs", openapi.DocsHandler) + + // Public routes (no auth) + r.Group(func(r chi.Router) { + r.Post("/auth/register", h.Auth.Register) + r.Post("/auth/login", h.Auth.Login) + r.Post("/auth/refresh", h.Auth.Refresh) + + r.Get("/booking/{token}/availability", h.Booking.GetAvailability) + r.Post("/booking/{token}/reserve", h.Booking.Reserve) + }) + + // Authenticated routes + r.Group(func(r chi.Router) { + r.Use(authMW.Authenticate) + + // Auth + r.Post("/auth/logout", h.Auth.Logout) + r.Get("/auth/me", h.Auth.Me) + + // Users + r.Get("/users/me", h.User.GetMe) + r.Put("/users/me", h.User.UpdateMe) + r.Delete("/users/me", h.User.DeleteMe) + + // API Keys + r.Post("/api-keys", h.APIKey.Create) + r.Get("/api-keys", h.APIKey.List) + r.Delete("/api-keys/{id}", h.APIKey.Revoke) + + // Calendars + r.Route("/calendars", func(r chi.Router) { + r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.List) + r.With(mw.RequireScope("calendars", "write")).Post("/", h.Calendar.Create) + r.With(mw.RequireScope("calendars", "write")).Post("/import", h.ICS.Import) + + r.Route("/{id}", func(r chi.Router) { + r.With(mw.RequireScope("calendars", "read")).Get("/", h.Calendar.Get) + r.With(mw.RequireScope("calendars", "write")).Put("/", h.Calendar.Update) + r.With(mw.RequireScope("calendars", "write")).Delete("/", h.Calendar.Delete) + + // Sharing + r.With(mw.RequireScope("calendars", "write")).Post("/share", h.Sharing.Share) + r.With(mw.RequireScope("calendars", "read")).Get("/members", h.Sharing.ListMembers) + r.With(mw.RequireScope("calendars", "write")).Delete("/members/{userID}", h.Sharing.RemoveMember) + + // Booking link + r.With(mw.RequireScope("booking", "write")).Post("/booking-link", h.Booking.CreateLink) + + // ICS + r.With(mw.RequireScope("calendars", "read")).Get("/export.ics", h.ICS.Export) + }) + }) + + // Events + r.Route("/events", func(r chi.Router) { + r.With(mw.RequireScope("events", "read")).Get("/", h.Event.List) + r.With(mw.RequireScope("events", "write")).Post("/", h.Event.Create) + + r.Route("/{id}", func(r chi.Router) { + r.With(mw.RequireScope("events", "read")).Get("/", h.Event.Get) + r.With(mw.RequireScope("events", "write")).Put("/", h.Event.Update) + r.With(mw.RequireScope("events", "write")).Delete("/", h.Event.Delete) + + // Reminders + r.With(mw.RequireScope("events", "write")).Post("/reminders", h.Reminder.Add) + r.With(mw.RequireScope("events", "write")).Delete("/reminders/{reminderID}", h.Reminder.Delete) + + // Attendees + r.With(mw.RequireScope("events", "write")).Post("/attendees", h.Attendee.Add) + r.With(mw.RequireScope("events", "write")).Put("/attendees/{attendeeID}", h.Attendee.UpdateStatus) + r.With(mw.RequireScope("events", "write")).Delete("/attendees/{attendeeID}", h.Attendee.Delete) + }) + }) + + // Contacts + r.Route("/contacts", func(r chi.Router) { + r.With(mw.RequireScope("contacts", "read")).Get("/", h.Contact.List) + r.With(mw.RequireScope("contacts", "write")).Post("/", h.Contact.Create) + + r.Route("/{id}", func(r chi.Router) { + r.With(mw.RequireScope("contacts", "read")).Get("/", h.Contact.Get) + r.With(mw.RequireScope("contacts", "write")).Put("/", h.Contact.Update) + r.With(mw.RequireScope("contacts", "write")).Delete("/", h.Contact.Delete) + }) + }) + + // Availability + r.With(mw.RequireScope("availability", "read")).Get("/availability", h.Availability.Get) + }) + + return r +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..1a4ee45 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,67 @@ +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + AccessTokenDuration = 15 * time.Minute + RefreshTokenDuration = 30 * 24 * time.Hour +) + +type JWTManager struct { + secret []byte +} + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + jwt.RegisteredClaims +} + +func NewJWTManager(secret string) *JWTManager { + return &JWTManager{secret: []byte(secret)} +} + +func (m *JWTManager) GenerateAccessToken(userID uuid.UUID) (string, error) { + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.secret) +} + +func (m *JWTManager) GenerateRefreshToken(userID uuid.UUID) (string, error) { + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.secret) +} + +func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return m.secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, jwt.ErrSignatureInvalid + } + return claims, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0896411 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "bufio" + "os" + "strings" +) + +type Config struct { + DatabaseURL string + JWTSecret string + RedisAddr string + ServerPort string + Env string +} + +func Load() *Config { + loadEnvFile(".env") + + return &Config{ + DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/calendarapi?sslmode=disable"), + JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"), + RedisAddr: os.Getenv("REDIS_ADDR"), + ServerPort: getEnv("SERVER_PORT", "8080"), + Env: getEnv("ENV", "development"), + } +} + +func loadEnvFile(path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + if os.Getenv(k) == "" { + os.Setenv(k, v) + } + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..7aab2c6 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + + "github.com/calendarapi/internal/auth" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/jackc/pgx/v5" +) + +type AuthMiddleware struct { + jwt *auth.JWTManager + queries *repository.Queries +} + +func NewAuthMiddleware(jwt *auth.JWTManager, queries *repository.Queries) *AuthMiddleware { + return &AuthMiddleware{jwt: jwt, queries: queries} +} + +func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if token := extractBearerToken(r); token != "" { + claims, err := m.jwt.ValidateToken(token) + if err != nil { + utils.WriteError(w, models.ErrAuthInvalid) + return + } + if _, err := m.queries.GetUserByID(ctx, utils.ToPgUUID(claims.UserID)); err != nil { + if err == pgx.ErrNoRows { + utils.WriteError(w, models.ErrAuthInvalid) + return + } + utils.WriteError(w, models.ErrInternal) + return + } + ctx = SetUserID(ctx, claims.UserID) + ctx = SetAuthMethod(ctx, "jwt") + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + if apiKey := r.Header.Get("X-API-Key"); apiKey != "" { + hash := SHA256Hash(apiKey) + key, err := m.queries.GetAPIKeyByHash(ctx, hash) + if err != nil { + if err == pgx.ErrNoRows { + utils.WriteError(w, models.ErrAuthInvalid) + return + } + utils.WriteError(w, models.ErrInternal) + return + } + + var scopes Scopes + if err := json.Unmarshal(key.Scopes, &scopes); err != nil { + utils.WriteError(w, models.ErrInternal) + return + } + + ctx = SetUserID(ctx, utils.FromPgUUID(key.UserID)) + ctx = SetAuthMethod(ctx, "api_key") + ctx = SetScopes(ctx, scopes) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + utils.WriteError(w, models.ErrAuthRequired) + }) +} + +func RequireScope(resource, action string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !HasScope(r.Context(), resource, action) { + utils.WriteError(w, models.ErrForbidden) + return + } + next.ServeHTTP(w, r.WithContext(r.Context())) + }) + } +} + +func extractBearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + return "" +} + +func SHA256Hash(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/middleware/context.go b/internal/middleware/context.go new file mode 100644 index 0000000..0ac6bde --- /dev/null +++ b/internal/middleware/context.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "context" + + "github.com/google/uuid" +) + +type contextKey string + +const ( + userIDKey contextKey = "user_id" + authMethodKey contextKey = "auth_method" + scopesKey contextKey = "scopes" +) + +type Scopes map[string][]string + +func SetUserID(ctx context.Context, id uuid.UUID) context.Context { + return context.WithValue(ctx, userIDKey, id) +} + +func GetUserID(ctx context.Context) (uuid.UUID, bool) { + id, ok := ctx.Value(userIDKey).(uuid.UUID) + return id, ok +} + +func SetAuthMethod(ctx context.Context, method string) context.Context { + return context.WithValue(ctx, authMethodKey, method) +} + +func GetAuthMethod(ctx context.Context) string { + m, _ := ctx.Value(authMethodKey).(string) + return m +} + +func SetScopes(ctx context.Context, scopes Scopes) context.Context { + return context.WithValue(ctx, scopesKey, scopes) +} + +func GetScopes(ctx context.Context) Scopes { + s, _ := ctx.Value(scopesKey).(Scopes) + return s +} + +func HasScope(ctx context.Context, resource, action string) bool { + if GetAuthMethod(ctx) == "jwt" { + return true + } + scopes := GetScopes(ctx) + if scopes == nil { + return false + } + actions, ok := scopes[resource] + if !ok { + return false + } + for _, a := range actions { + if a == action { + return true + } + } + return false +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..972aa3b --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/utils" +) + +type visitor struct { + tokens float64 + lastSeen time.Time +} + +type RateLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + rate float64 + burst float64 +} + +func NewRateLimiter(ratePerSecond float64, burst int) *RateLimiter { + rl := &RateLimiter{ + visitors: make(map[string]*visitor), + rate: ratePerSecond, + burst: float64(burst), + } + go rl.cleanup() + return rl +} + +func (rl *RateLimiter) Limit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + ip = fwd + } + + if !rl.allow(ip) { + utils.WriteError(w, models.ErrRateLimited) + return + } + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) allow(key string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + v, exists := rl.visitors[key] + now := time.Now() + + if !exists { + rl.visitors[key] = &visitor{tokens: rl.burst - 1, lastSeen: now} + return true + } + + elapsed := now.Sub(v.lastSeen).Seconds() + v.tokens += elapsed * rl.rate + if v.tokens > rl.burst { + v.tokens = rl.burst + } + v.lastSeen = now + + if v.tokens < 1 { + return false + } + v.tokens-- + return true +} + +func (rl *RateLimiter) cleanup() { + for { + time.Sleep(5 * time.Minute) + rl.mu.Lock() + for key, v := range rl.visitors { + if time.Since(v.lastSeen) > 10*time.Minute { + delete(rl.visitors, key) + } + } + rl.mu.Unlock() + } +} diff --git a/internal/models/errors.go b/internal/models/errors.go new file mode 100644 index 0000000..f3e5a81 --- /dev/null +++ b/internal/models/errors.go @@ -0,0 +1,52 @@ +package models + +import ( + "errors" + "net/http" +) + +type AppError struct { + Status int `json:"-"` + Code string `json:"code"` + Message string `json:"error"` + Details string `json:"details,omitempty"` +} + +func (e *AppError) Error() string { + return e.Message +} + +var ( + ErrAuthRequired = &AppError{Status: http.StatusUnauthorized, Code: "AUTH_REQUIRED", Message: "Authentication required"} + ErrAuthInvalid = &AppError{Status: http.StatusUnauthorized, Code: "AUTH_INVALID", Message: "Invalid credentials"} + ErrForbidden = &AppError{Status: http.StatusForbidden, Code: "FORBIDDEN", Message: "Permission denied"} + ErrNotFound = &AppError{Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"} + ErrValidation = &AppError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "Validation failed"} + ErrConflict = &AppError{Status: http.StatusConflict, Code: "CONFLICT", Message: "Resource conflict"} + ErrRateLimited = &AppError{Status: http.StatusTooManyRequests, Code: "RATE_LIMITED", Message: "Too many requests"} + ErrInternal = &AppError{Status: http.StatusInternalServerError, Code: "INTERNAL", Message: "Internal server error"} +) + +func NewValidationError(detail string) *AppError { + return &AppError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "Validation failed", Details: detail} +} + +func NewConflictError(detail string) *AppError { + return &AppError{Status: http.StatusConflict, Code: "CONFLICT", Message: detail} +} + +func NewNotFoundError(detail string) *AppError { + return &AppError{Status: http.StatusNotFound, Code: "NOT_FOUND", Message: detail} +} + +func NewForbiddenError(detail string) *AppError { + return &AppError{Status: http.StatusForbidden, Code: "FORBIDDEN", Message: detail} +} + +func IsAppError(err error) (*AppError, bool) { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr, true + } + return nil, false +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..7e392b9 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,164 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Timezone string `json:"timezone"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Calendar struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + IsPublic bool `json:"is_public"` + Role string `json:"role,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Event struct { + ID uuid.UUID `json:"id"` + CalendarID uuid.UUID `json:"calendar_id"` + Title string `json:"title"` + Description *string `json:"description"` + Location *string `json:"location"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Timezone string `json:"timezone"` + AllDay bool `json:"all_day"` + RecurrenceRule *string `json:"recurrence_rule"` + IsOccurrence bool `json:"is_occurrence,omitempty"` + OccurrenceStartTime *time.Time `json:"occurrence_start_time,omitempty"` + OccurrenceEndTime *time.Time `json:"occurrence_end_time,omitempty"` + CreatedBy uuid.UUID `json:"created_by"` + UpdatedBy uuid.UUID `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Reminders []Reminder `json:"reminders"` + Attendees []Attendee `json:"attendees"` + Tags []string `json:"tags"` + Attachments []Attachment `json:"attachments"` +} + +type Reminder struct { + ID uuid.UUID `json:"id"` + MinutesBefore int32 `json:"minutes_before"` +} + +type Attendee struct { + ID uuid.UUID `json:"id"` + UserID *uuid.UUID `json:"user_id"` + Email *string `json:"email"` + Status string `json:"status"` +} + +type Attachment struct { + ID uuid.UUID `json:"id"` + FileURL string `json:"file_url"` +} + +type Contact struct { + ID uuid.UUID `json:"id"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + Email *string `json:"email"` + Phone *string `json:"phone"` + Company *string `json:"company"` + Notes *string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type APIKeyResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + RevokedAt *time.Time `json:"revoked_at"` + Token string `json:"token,omitempty"` +} + +type CalendarMember struct { + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` +} + +type BusyBlock struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + EventID uuid.UUID `json:"event_id"` +} + +type AvailabilityResponse struct { + CalendarID uuid.UUID `json:"calendar_id"` + RangeStart time.Time `json:"range_start"` + RangeEnd time.Time `json:"range_end"` + Busy []BusyBlock `json:"busy"` +} + +type BookingLink struct { + Token string `json:"token"` + PublicURL string `json:"public_url,omitempty"` + Settings BookingConfig `json:"settings"` +} + +type BookingConfig struct { + DurationMinutes int `json:"duration_minutes"` + BufferMinutes int `json:"buffer_minutes"` + Timezone string `json:"timezone"` + WorkingHours map[string][]Slot `json:"working_hours"` + Active bool `json:"active"` +} + +type Slot struct { + Start string `json:"start"` + End string `json:"end"` +} + +type BookingAvailability struct { + Token string `json:"token"` + Timezone string `json:"timezone"` + DurationMinutes int `json:"duration_minutes"` + Slots []TimeSlot `json:"slots"` +} + +type TimeSlot struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` +} + +type PageInfo struct { + Limit int `json:"limit"` + NextCursor *string `json:"next_cursor"` +} + +type ListResponse struct { + Items interface{} `json:"items"` + Page PageInfo `json:"page"` +} + +type AuthTokens struct { + User User `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type AuditEntry struct { + EntityType string `json:"entity_type"` + EntityID uuid.UUID `json:"entity_id"` + Action string `json:"action"` + UserID uuid.UUID `json:"user_id"` +} diff --git a/internal/repository/api_keys.sql.go b/internal/repository/api_keys.sql.go new file mode 100644 index 0000000..859ee3d --- /dev/null +++ b/internal/repository/api_keys.sql.go @@ -0,0 +1,134 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: api_keys.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAPIKey = `-- name: CreateAPIKey :one +INSERT INTO api_keys (id, user_id, name, key_hash, scopes) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, user_id, name, key_hash, scopes, created_at, revoked_at +` + +type CreateAPIKeyParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + Scopes []byte `json:"scopes"` +} + +func (q *Queries) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (ApiKey, error) { + row := q.db.QueryRow(ctx, createAPIKey, + arg.ID, + arg.UserID, + arg.Name, + arg.KeyHash, + arg.Scopes, + ) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.Scopes, + &i.CreatedAt, + &i.RevokedAt, + ) + return i, err +} + +const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one +SELECT id, user_id, name, key_hash, scopes, created_at, revoked_at +FROM api_keys +WHERE key_hash = $1 AND revoked_at IS NULL +` + +func (q *Queries) GetAPIKeyByHash(ctx context.Context, keyHash string) (ApiKey, error) { + row := q.db.QueryRow(ctx, getAPIKeyByHash, keyHash) + var i ApiKey + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.KeyHash, + &i.Scopes, + &i.CreatedAt, + &i.RevokedAt, + ) + return i, err +} + +const listAPIKeysByUser = `-- name: ListAPIKeysByUser :many +SELECT id, name, scopes, created_at, revoked_at +FROM api_keys +WHERE user_id = $1 +ORDER BY created_at DESC +` + +type ListAPIKeysByUserRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Scopes []byte `json:"scopes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` +} + +func (q *Queries) ListAPIKeysByUser(ctx context.Context, userID pgtype.UUID) ([]ListAPIKeysByUserRow, error) { + rows, err := q.db.Query(ctx, listAPIKeysByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAPIKeysByUserRow{} + for rows.Next() { + var i ListAPIKeysByUserRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Scopes, + &i.CreatedAt, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeAPIKey = `-- name: RevokeAPIKey :exec +UPDATE api_keys SET revoked_at = now() +WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL +` + +type RevokeAPIKeyParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) error { + _, err := q.db.Exec(ctx, revokeAPIKey, arg.ID, arg.UserID) + return err +} + +const revokeAllUserAPIKeys = `-- name: RevokeAllUserAPIKeys :exec +UPDATE api_keys SET revoked_at = now() +WHERE user_id = $1 AND revoked_at IS NULL +` + +func (q *Queries) RevokeAllUserAPIKeys(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, revokeAllUserAPIKeys, userID) + return err +} diff --git a/internal/repository/attachments.sql.go b/internal/repository/attachments.sql.go new file mode 100644 index 0000000..93b62e3 --- /dev/null +++ b/internal/repository/attachments.sql.go @@ -0,0 +1,72 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: attachments.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAttachment = `-- name: CreateAttachment :one +INSERT INTO event_attachments (id, event_id, file_url) +VALUES ($1, $2, $3) +RETURNING id, event_id, file_url +` + +type CreateAttachmentParams struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + FileUrl string `json:"file_url"` +} + +func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (EventAttachment, error) { + row := q.db.QueryRow(ctx, createAttachment, arg.ID, arg.EventID, arg.FileUrl) + var i EventAttachment + err := row.Scan(&i.ID, &i.EventID, &i.FileUrl) + return i, err +} + +const deleteAttachment = `-- name: DeleteAttachment :exec +DELETE FROM event_attachments WHERE id = $1 AND event_id = $2 +` + +type DeleteAttachmentParams struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` +} + +func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error { + _, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.EventID) + return err +} + +const listAttachmentsByEvent = `-- name: ListAttachmentsByEvent :many +SELECT id, event_id, file_url +FROM event_attachments +WHERE event_id = $1 +ORDER BY id ASC +` + +func (q *Queries) ListAttachmentsByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventAttachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByEvent, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []EventAttachment{} + for rows.Next() { + var i EventAttachment + if err := rows.Scan(&i.ID, &i.EventID, &i.FileUrl); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/repository/attendees.sql.go b/internal/repository/attendees.sql.go new file mode 100644 index 0000000..6c3d25a --- /dev/null +++ b/internal/repository/attendees.sql.go @@ -0,0 +1,135 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: attendees.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAttendee = `-- name: CreateAttendee :one +INSERT INTO event_attendees (id, event_id, user_id, email, status) +VALUES ($1, $2, $3, $4, 'pending') +RETURNING id, event_id, user_id, email, status +` + +type CreateAttendeeParams struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + UserID pgtype.UUID `json:"user_id"` + Email pgtype.Text `json:"email"` +} + +func (q *Queries) CreateAttendee(ctx context.Context, arg CreateAttendeeParams) (EventAttendee, error) { + row := q.db.QueryRow(ctx, createAttendee, + arg.ID, + arg.EventID, + arg.UserID, + arg.Email, + ) + var i EventAttendee + err := row.Scan( + &i.ID, + &i.EventID, + &i.UserID, + &i.Email, + &i.Status, + ) + return i, err +} + +const deleteAttendee = `-- name: DeleteAttendee :exec +DELETE FROM event_attendees +WHERE id = $1 AND event_id = $2 +` + +type DeleteAttendeeParams struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` +} + +func (q *Queries) DeleteAttendee(ctx context.Context, arg DeleteAttendeeParams) error { + _, err := q.db.Exec(ctx, deleteAttendee, arg.ID, arg.EventID) + return err +} + +const getAttendeeByID = `-- name: GetAttendeeByID :one +SELECT id, event_id, user_id, email, status +FROM event_attendees +WHERE id = $1 +` + +func (q *Queries) GetAttendeeByID(ctx context.Context, id pgtype.UUID) (EventAttendee, error) { + row := q.db.QueryRow(ctx, getAttendeeByID, id) + var i EventAttendee + err := row.Scan( + &i.ID, + &i.EventID, + &i.UserID, + &i.Email, + &i.Status, + ) + return i, err +} + +const listAttendeesByEvent = `-- name: ListAttendeesByEvent :many +SELECT id, event_id, user_id, email, status +FROM event_attendees +WHERE event_id = $1 +ORDER BY id ASC +` + +func (q *Queries) ListAttendeesByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventAttendee, error) { + rows, err := q.db.Query(ctx, listAttendeesByEvent, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []EventAttendee{} + for rows.Next() { + var i EventAttendee + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.UserID, + &i.Email, + &i.Status, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAttendeeStatus = `-- name: UpdateAttendeeStatus :one +UPDATE event_attendees +SET status = $2 +WHERE id = $1 +RETURNING id, event_id, user_id, email, status +` + +type UpdateAttendeeStatusParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) UpdateAttendeeStatus(ctx context.Context, arg UpdateAttendeeStatusParams) (EventAttendee, error) { + row := q.db.QueryRow(ctx, updateAttendeeStatus, arg.ID, arg.Status) + var i EventAttendee + err := row.Scan( + &i.ID, + &i.EventID, + &i.UserID, + &i.Email, + &i.Status, + ) + return i, err +} diff --git a/internal/repository/audit_logs.sql.go b/internal/repository/audit_logs.sql.go new file mode 100644 index 0000000..29a7fee --- /dev/null +++ b/internal/repository/audit_logs.sql.go @@ -0,0 +1,34 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: audit_logs.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createAuditLog = `-- name: CreateAuditLog :exec +INSERT INTO audit_logs (entity_type, entity_id, action, user_id) +VALUES ($1, $2, $3, $4) +` + +type CreateAuditLogParams struct { + EntityType string `json:"entity_type"` + EntityID pgtype.UUID `json:"entity_id"` + Action string `json:"action"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) error { + _, err := q.db.Exec(ctx, createAuditLog, + arg.EntityType, + arg.EntityID, + arg.Action, + arg.UserID, + ) + return err +} diff --git a/internal/repository/booking_links.sql.go b/internal/repository/booking_links.sql.go new file mode 100644 index 0000000..b9d28c4 --- /dev/null +++ b/internal/repository/booking_links.sql.go @@ -0,0 +1,148 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: booking_links.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createBookingLink = `-- name: CreateBookingLink :one +INSERT INTO booking_links (id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at +` + +type CreateBookingLinkParams struct { + ID pgtype.UUID `json:"id"` + CalendarID pgtype.UUID `json:"calendar_id"` + Token string `json:"token"` + DurationMinutes int32 `json:"duration_minutes"` + BufferMinutes int32 `json:"buffer_minutes"` + Timezone string `json:"timezone"` + WorkingHours []byte `json:"working_hours"` + Active bool `json:"active"` +} + +func (q *Queries) CreateBookingLink(ctx context.Context, arg CreateBookingLinkParams) (BookingLink, error) { + row := q.db.QueryRow(ctx, createBookingLink, + arg.ID, + arg.CalendarID, + arg.Token, + arg.DurationMinutes, + arg.BufferMinutes, + arg.Timezone, + arg.WorkingHours, + arg.Active, + ) + var i BookingLink + err := row.Scan( + &i.ID, + &i.CalendarID, + &i.Token, + &i.DurationMinutes, + &i.BufferMinutes, + &i.Timezone, + &i.WorkingHours, + &i.Active, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getBookingLinkByCalendar = `-- name: GetBookingLinkByCalendar :one +SELECT id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at FROM booking_links +WHERE calendar_id = $1 +` + +func (q *Queries) GetBookingLinkByCalendar(ctx context.Context, calendarID pgtype.UUID) (BookingLink, error) { + row := q.db.QueryRow(ctx, getBookingLinkByCalendar, calendarID) + var i BookingLink + err := row.Scan( + &i.ID, + &i.CalendarID, + &i.Token, + &i.DurationMinutes, + &i.BufferMinutes, + &i.Timezone, + &i.WorkingHours, + &i.Active, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getBookingLinkByToken = `-- name: GetBookingLinkByToken :one +SELECT id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at FROM booking_links +WHERE token = $1 +` + +func (q *Queries) GetBookingLinkByToken(ctx context.Context, token string) (BookingLink, error) { + row := q.db.QueryRow(ctx, getBookingLinkByToken, token) + var i BookingLink + err := row.Scan( + &i.ID, + &i.CalendarID, + &i.Token, + &i.DurationMinutes, + &i.BufferMinutes, + &i.Timezone, + &i.WorkingHours, + &i.Active, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateBookingLink = `-- name: UpdateBookingLink :one +UPDATE booking_links +SET duration_minutes = COALESCE($2, duration_minutes), + buffer_minutes = COALESCE($3, buffer_minutes), + timezone = COALESCE($4, timezone), + working_hours = COALESCE($5, working_hours), + active = COALESCE($6, active), + updated_at = now() +WHERE id = $1 +RETURNING id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active, created_at, updated_at +` + +type UpdateBookingLinkParams struct { + ID pgtype.UUID `json:"id"` + DurationMinutes int32 `json:"duration_minutes"` + BufferMinutes int32 `json:"buffer_minutes"` + Timezone string `json:"timezone"` + WorkingHours []byte `json:"working_hours"` + Active bool `json:"active"` +} + +func (q *Queries) UpdateBookingLink(ctx context.Context, arg UpdateBookingLinkParams) (BookingLink, error) { + row := q.db.QueryRow(ctx, updateBookingLink, + arg.ID, + arg.DurationMinutes, + arg.BufferMinutes, + arg.Timezone, + arg.WorkingHours, + arg.Active, + ) + var i BookingLink + err := row.Scan( + &i.ID, + &i.CalendarID, + &i.Token, + &i.DurationMinutes, + &i.BufferMinutes, + &i.Timezone, + &i.WorkingHours, + &i.Active, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/repository/calendar_members.sql.go b/internal/repository/calendar_members.sql.go new file mode 100644 index 0000000..aef60be --- /dev/null +++ b/internal/repository/calendar_members.sql.go @@ -0,0 +1,105 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: calendar_members.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteAllCalendarMembers = `-- name: DeleteAllCalendarMembers :exec +DELETE FROM calendar_members +WHERE calendar_id = $1 +` + +func (q *Queries) DeleteAllCalendarMembers(ctx context.Context, calendarID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteAllCalendarMembers, calendarID) + return err +} + +const deleteCalendarMember = `-- name: DeleteCalendarMember :exec +DELETE FROM calendar_members +WHERE calendar_id = $1 AND user_id = $2 +` + +type DeleteCalendarMemberParams struct { + CalendarID pgtype.UUID `json:"calendar_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) DeleteCalendarMember(ctx context.Context, arg DeleteCalendarMemberParams) error { + _, err := q.db.Exec(ctx, deleteCalendarMember, arg.CalendarID, arg.UserID) + return err +} + +const getCalendarMemberRole = `-- name: GetCalendarMemberRole :one +SELECT role FROM calendar_members +WHERE calendar_id = $1 AND user_id = $2 +` + +type GetCalendarMemberRoleParams struct { + CalendarID pgtype.UUID `json:"calendar_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) GetCalendarMemberRole(ctx context.Context, arg GetCalendarMemberRoleParams) (string, error) { + row := q.db.QueryRow(ctx, getCalendarMemberRole, arg.CalendarID, arg.UserID) + var role string + err := row.Scan(&role) + return role, err +} + +const listCalendarMembers = `-- name: ListCalendarMembers :many +SELECT cm.user_id, u.email, cm.role +FROM calendar_members cm +JOIN users u ON u.id = cm.user_id +WHERE cm.calendar_id = $1 AND u.deleted_at IS NULL +ORDER BY cm.role ASC +` + +type ListCalendarMembersRow struct { + UserID pgtype.UUID `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` +} + +func (q *Queries) ListCalendarMembers(ctx context.Context, calendarID pgtype.UUID) ([]ListCalendarMembersRow, error) { + rows, err := q.db.Query(ctx, listCalendarMembers, calendarID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListCalendarMembersRow{} + for rows.Next() { + var i ListCalendarMembersRow + if err := rows.Scan(&i.UserID, &i.Email, &i.Role); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertCalendarMember = `-- name: UpsertCalendarMember :exec +INSERT INTO calendar_members (calendar_id, user_id, role) +VALUES ($1, $2, $3) +ON CONFLICT (calendar_id, user_id) DO UPDATE SET role = $3 +` + +type UpsertCalendarMemberParams struct { + CalendarID pgtype.UUID `json:"calendar_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` +} + +func (q *Queries) UpsertCalendarMember(ctx context.Context, arg UpsertCalendarMemberParams) error { + _, err := q.db.Exec(ctx, upsertCalendarMember, arg.CalendarID, arg.UserID, arg.Role) + return err +} diff --git a/internal/repository/calendars.sql.go b/internal/repository/calendars.sql.go new file mode 100644 index 0000000..73fbb30 --- /dev/null +++ b/internal/repository/calendars.sql.go @@ -0,0 +1,209 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: calendars.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createCalendar = `-- name: CreateCalendar :one +INSERT INTO calendars (id, owner_id, name, color, is_public) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, owner_id, name, color, is_public, public_token, created_at, updated_at +` + +type CreateCalendarParams struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + Name string `json:"name"` + Color string `json:"color"` + IsPublic bool `json:"is_public"` +} + +type CreateCalendarRow struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + Name string `json:"name"` + Color string `json:"color"` + IsPublic bool `json:"is_public"` + PublicToken pgtype.Text `json:"public_token"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) CreateCalendar(ctx context.Context, arg CreateCalendarParams) (CreateCalendarRow, error) { + row := q.db.QueryRow(ctx, createCalendar, + arg.ID, + arg.OwnerID, + arg.Name, + arg.Color, + arg.IsPublic, + ) + var i CreateCalendarRow + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.Name, + &i.Color, + &i.IsPublic, + &i.PublicToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getCalendarByID = `-- name: GetCalendarByID :one +SELECT id, owner_id, name, color, is_public, public_token, created_at, updated_at +FROM calendars +WHERE id = $1 AND deleted_at IS NULL +` + +type GetCalendarByIDRow struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + Name string `json:"name"` + Color string `json:"color"` + IsPublic bool `json:"is_public"` + PublicToken pgtype.Text `json:"public_token"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetCalendarByID(ctx context.Context, id pgtype.UUID) (GetCalendarByIDRow, error) { + row := q.db.QueryRow(ctx, getCalendarByID, id) + var i GetCalendarByIDRow + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.Name, + &i.Color, + &i.IsPublic, + &i.PublicToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listCalendarsByUser = `-- name: ListCalendarsByUser :many +SELECT c.id, c.owner_id, c.name, c.color, c.is_public, c.created_at, c.updated_at, cm.role +FROM calendars c +JOIN calendar_members cm ON cm.calendar_id = c.id +WHERE cm.user_id = $1 AND c.deleted_at IS NULL +ORDER BY c.created_at ASC +` + +type ListCalendarsByUserRow struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + Name string `json:"name"` + Color string `json:"color"` + IsPublic bool `json:"is_public"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Role string `json:"role"` +} + +func (q *Queries) ListCalendarsByUser(ctx context.Context, userID pgtype.UUID) ([]ListCalendarsByUserRow, error) { + rows, err := q.db.Query(ctx, listCalendarsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListCalendarsByUserRow{} + for rows.Next() { + var i ListCalendarsByUserRow + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.Name, + &i.Color, + &i.IsPublic, + &i.CreatedAt, + &i.UpdatedAt, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const softDeleteCalendar = `-- name: SoftDeleteCalendar :exec +UPDATE calendars SET deleted_at = now(), updated_at = now() +WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteCalendar(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteCalendar, id) + return err +} + +const softDeleteCalendarsByOwner = `-- name: SoftDeleteCalendarsByOwner :exec +UPDATE calendars SET deleted_at = now(), updated_at = now() +WHERE owner_id = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteCalendarsByOwner(ctx context.Context, ownerID pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteCalendarsByOwner, ownerID) + return err +} + +const updateCalendar = `-- name: UpdateCalendar :one +UPDATE calendars +SET name = COALESCE($1::TEXT, name), + color = COALESCE($2::TEXT, color), + is_public = COALESCE($3::BOOLEAN, is_public), + updated_at = now() +WHERE id = $4 AND deleted_at IS NULL +RETURNING id, owner_id, name, color, is_public, public_token, created_at, updated_at +` + +type UpdateCalendarParams struct { + Name pgtype.Text `json:"name"` + Color pgtype.Text `json:"color"` + IsPublic pgtype.Bool `json:"is_public"` + ID pgtype.UUID `json:"id"` +} + +type UpdateCalendarRow struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + Name string `json:"name"` + Color string `json:"color"` + IsPublic bool `json:"is_public"` + PublicToken pgtype.Text `json:"public_token"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpdateCalendar(ctx context.Context, arg UpdateCalendarParams) (UpdateCalendarRow, error) { + row := q.db.QueryRow(ctx, updateCalendar, + arg.Name, + arg.Color, + arg.IsPublic, + arg.ID, + ) + var i UpdateCalendarRow + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.Name, + &i.Color, + &i.IsPublic, + &i.PublicToken, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/repository/contacts.sql.go b/internal/repository/contacts.sql.go new file mode 100644 index 0000000..7ce6432 --- /dev/null +++ b/internal/repository/contacts.sql.go @@ -0,0 +1,228 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: contacts.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createContact = `-- name: CreateContact :one +INSERT INTO contacts (id, owner_id, first_name, last_name, email, phone, company, notes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at +` + +type CreateContactParams struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + FirstName pgtype.Text `json:"first_name"` + LastName pgtype.Text `json:"last_name"` + Email pgtype.Text `json:"email"` + Phone pgtype.Text `json:"phone"` + Company pgtype.Text `json:"company"` + Notes pgtype.Text `json:"notes"` +} + +func (q *Queries) CreateContact(ctx context.Context, arg CreateContactParams) (Contact, error) { + row := q.db.QueryRow(ctx, createContact, + arg.ID, + arg.OwnerID, + arg.FirstName, + arg.LastName, + arg.Email, + arg.Phone, + arg.Company, + arg.Notes, + ) + var i Contact + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.Phone, + &i.Company, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getContactByID = `-- name: GetContactByID :one +SELECT id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at FROM contacts +WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL +` + +type GetContactByIDParams struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` +} + +func (q *Queries) GetContactByID(ctx context.Context, arg GetContactByIDParams) (Contact, error) { + row := q.db.QueryRow(ctx, getContactByID, arg.ID, arg.OwnerID) + var i Contact + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.Phone, + &i.Company, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const listContacts = `-- name: ListContacts :many +SELECT id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at FROM contacts +WHERE owner_id = $1 + AND deleted_at IS NULL + AND ( + $2::TEXT IS NULL + OR first_name ILIKE '%' || $2::TEXT || '%' + OR last_name ILIKE '%' || $2::TEXT || '%' + OR email ILIKE '%' || $2::TEXT || '%' + OR company ILIKE '%' || $2::TEXT || '%' + ) + AND ( + $3::TIMESTAMPTZ IS NULL + OR (created_at, id) > ($3::TIMESTAMPTZ, $4::UUID) + ) +ORDER BY created_at ASC, id ASC +LIMIT $5 +` + +type ListContactsParams struct { + OwnerID pgtype.UUID `json:"owner_id"` + Search pgtype.Text `json:"search"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID pgtype.UUID `json:"cursor_id"` + Lim int32 `json:"lim"` +} + +func (q *Queries) ListContacts(ctx context.Context, arg ListContactsParams) ([]Contact, error) { + rows, err := q.db.Query(ctx, listContacts, + arg.OwnerID, + arg.Search, + arg.CursorTime, + arg.CursorID, + arg.Lim, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Contact{} + for rows.Next() { + var i Contact + if err := rows.Scan( + &i.ID, + &i.OwnerID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.Phone, + &i.Company, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const softDeleteContact = `-- name: SoftDeleteContact :exec +UPDATE contacts SET deleted_at = now(), updated_at = now() +WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL +` + +type SoftDeleteContactParams struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` +} + +func (q *Queries) SoftDeleteContact(ctx context.Context, arg SoftDeleteContactParams) error { + _, err := q.db.Exec(ctx, softDeleteContact, arg.ID, arg.OwnerID) + return err +} + +const softDeleteContactsByOwner = `-- name: SoftDeleteContactsByOwner :exec +UPDATE contacts SET deleted_at = now(), updated_at = now() +WHERE owner_id = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteContactsByOwner(ctx context.Context, ownerID pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteContactsByOwner, ownerID) + return err +} + +const updateContact = `-- name: UpdateContact :one +UPDATE contacts +SET first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + email = COALESCE($3, email), + phone = COALESCE($4, phone), + company = COALESCE($5, company), + notes = COALESCE($6, notes), + updated_at = now() +WHERE id = $7 AND owner_id = $8 AND deleted_at IS NULL +RETURNING id, owner_id, first_name, last_name, email, phone, company, notes, created_at, updated_at, deleted_at +` + +type UpdateContactParams struct { + FirstName pgtype.Text `json:"first_name"` + LastName pgtype.Text `json:"last_name"` + Email pgtype.Text `json:"email"` + Phone pgtype.Text `json:"phone"` + Company pgtype.Text `json:"company"` + Notes pgtype.Text `json:"notes"` + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` +} + +func (q *Queries) UpdateContact(ctx context.Context, arg UpdateContactParams) (Contact, error) { + row := q.db.QueryRow(ctx, updateContact, + arg.FirstName, + arg.LastName, + arg.Email, + arg.Phone, + arg.Company, + arg.Notes, + arg.ID, + arg.OwnerID, + ) + var i Contact + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.Phone, + &i.Company, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} diff --git a/internal/repository/db.go b/internal/repository/db.go new file mode 100644 index 0000000..b196af2 --- /dev/null +++ b/internal/repository/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/repository/event_exceptions.sql.go b/internal/repository/event_exceptions.sql.go new file mode 100644 index 0000000..d8af051 --- /dev/null +++ b/internal/repository/event_exceptions.sql.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: event_exceptions.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createEventException = `-- name: CreateEventException :one +INSERT INTO event_exceptions (id, event_id, exception_date, action) +VALUES ($1, $2, $3, $4) +RETURNING id, event_id, exception_date, action +` + +type CreateEventExceptionParams struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + ExceptionDate pgtype.Date `json:"exception_date"` + Action string `json:"action"` +} + +func (q *Queries) CreateEventException(ctx context.Context, arg CreateEventExceptionParams) (EventException, error) { + row := q.db.QueryRow(ctx, createEventException, + arg.ID, + arg.EventID, + arg.ExceptionDate, + arg.Action, + ) + var i EventException + err := row.Scan( + &i.ID, + &i.EventID, + &i.ExceptionDate, + &i.Action, + ) + return i, err +} + +const listExceptionsByEvent = `-- name: ListExceptionsByEvent :many +SELECT id, event_id, exception_date, action +FROM event_exceptions +WHERE event_id = $1 +ORDER BY exception_date ASC +` + +func (q *Queries) ListExceptionsByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventException, error) { + rows, err := q.db.Query(ctx, listExceptionsByEvent, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []EventException{} + for rows.Next() { + var i EventException + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.ExceptionDate, + &i.Action, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/repository/events.sql.go b/internal/repository/events.sql.go new file mode 100644 index 0000000..9ccd6f5 --- /dev/null +++ b/internal/repository/events.sql.go @@ -0,0 +1,479 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: events.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const checkEventOverlap = `-- name: CheckEventOverlap :one +SELECT EXISTS( + SELECT 1 FROM events + WHERE calendar_id = $1 + AND deleted_at IS NULL + AND start_time < $3 + AND end_time > $2 +) AS overlap +` + +type CheckEventOverlapParams struct { + CalendarID pgtype.UUID `json:"calendar_id"` + EndTime pgtype.Timestamptz `json:"end_time"` + StartTime pgtype.Timestamptz `json:"start_time"` +} + +func (q *Queries) CheckEventOverlap(ctx context.Context, arg CheckEventOverlapParams) (bool, error) { + row := q.db.QueryRow(ctx, checkEventOverlap, arg.CalendarID, arg.EndTime, arg.StartTime) + var overlap bool + err := row.Scan(&overlap) + return overlap, err +} + +const checkEventOverlapForUpdate = `-- name: CheckEventOverlapForUpdate :one +SELECT EXISTS( + SELECT 1 FROM events + WHERE calendar_id = $1 + AND deleted_at IS NULL + AND start_time < $3 + AND end_time > $2 + FOR UPDATE +) AS overlap +` + +type CheckEventOverlapForUpdateParams struct { + CalendarID pgtype.UUID `json:"calendar_id"` + EndTime pgtype.Timestamptz `json:"end_time"` + StartTime pgtype.Timestamptz `json:"start_time"` +} + +func (q *Queries) CheckEventOverlapForUpdate(ctx context.Context, arg CheckEventOverlapForUpdateParams) (bool, error) { + row := q.db.QueryRow(ctx, checkEventOverlapForUpdate, arg.CalendarID, arg.EndTime, arg.StartTime) + var overlap bool + err := row.Scan(&overlap) + return overlap, err +} + +const createEvent = `-- name: CreateEvent :one +INSERT INTO events (id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +RETURNING id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at +` + +type CreateEventParams struct { + ID pgtype.UUID `json:"id"` + CalendarID pgtype.UUID `json:"calendar_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Location pgtype.Text `json:"location"` + StartTime pgtype.Timestamptz `json:"start_time"` + EndTime pgtype.Timestamptz `json:"end_time"` + Timezone string `json:"timezone"` + AllDay bool `json:"all_day"` + RecurrenceRule pgtype.Text `json:"recurrence_rule"` + Tags []string `json:"tags"` + CreatedBy pgtype.UUID `json:"created_by"` + UpdatedBy pgtype.UUID `json:"updated_by"` +} + +func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { + row := q.db.QueryRow(ctx, createEvent, + arg.ID, + arg.CalendarID, + arg.Title, + arg.Description, + arg.Location, + arg.StartTime, + arg.EndTime, + arg.Timezone, + arg.AllDay, + arg.RecurrenceRule, + arg.Tags, + arg.CreatedBy, + arg.UpdatedBy, + ) + var i Event + err := row.Scan( + &i.ID, + &i.CalendarID, + &i.Title, + &i.Description, + &i.Location, + &i.StartTime, + &i.EndTime, + &i.Timezone, + &i.AllDay, + &i.RecurrenceRule, + &i.Tags, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getEventByID = `-- name: GetEventByID :one +SELECT id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at FROM events +WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) GetEventByID(ctx context.Context, id pgtype.UUID) (Event, error) { + row := q.db.QueryRow(ctx, getEventByID, id) + var i Event + err := row.Scan( + &i.ID, + &i.CalendarID, + &i.Title, + &i.Description, + &i.Location, + &i.StartTime, + &i.EndTime, + &i.Timezone, + &i.AllDay, + &i.RecurrenceRule, + &i.Tags, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const listEventsByCalendarInRange = `-- name: ListEventsByCalendarInRange :many +SELECT id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at FROM events +WHERE calendar_id = $1 + AND deleted_at IS NULL + AND start_time < $3 + AND end_time > $2 +ORDER BY start_time ASC +` + +type ListEventsByCalendarInRangeParams struct { + CalendarID pgtype.UUID `json:"calendar_id"` + EndTime pgtype.Timestamptz `json:"end_time"` + StartTime pgtype.Timestamptz `json:"start_time"` +} + +func (q *Queries) ListEventsByCalendarInRange(ctx context.Context, arg ListEventsByCalendarInRangeParams) ([]Event, error) { + rows, err := q.db.Query(ctx, listEventsByCalendarInRange, arg.CalendarID, arg.EndTime, arg.StartTime) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Event{} + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.CalendarID, + &i.Title, + &i.Description, + &i.Location, + &i.StartTime, + &i.EndTime, + &i.Timezone, + &i.AllDay, + &i.RecurrenceRule, + &i.Tags, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEventsInRange = `-- name: ListEventsInRange :many +SELECT e.id, e.calendar_id, e.title, e.description, e.location, e.start_time, e.end_time, e.timezone, e.all_day, e.recurrence_rule, e.tags, e.created_by, e.updated_by, e.created_at, e.updated_at, e.deleted_at FROM events e +JOIN calendar_members cm ON cm.calendar_id = e.calendar_id +WHERE cm.user_id = $1 + AND e.deleted_at IS NULL + AND e.start_time < $2 + AND e.end_time > $3 + AND ($4::UUID IS NULL OR e.calendar_id = $4::UUID) + AND ($5::TEXT IS NULL OR (e.title ILIKE '%' || $5::TEXT || '%' OR e.description ILIKE '%' || $5::TEXT || '%')) + AND ($6::TEXT IS NULL OR $6::TEXT = ANY(e.tags)) + AND ( + $7::TIMESTAMPTZ IS NULL + OR (e.start_time, e.id) > ($7::TIMESTAMPTZ, $8::UUID) + ) +ORDER BY e.start_time ASC, e.id ASC +LIMIT $9 +` + +type ListEventsInRangeParams struct { + UserID pgtype.UUID `json:"user_id"` + RangeEnd pgtype.Timestamptz `json:"range_end"` + RangeStart pgtype.Timestamptz `json:"range_start"` + CalendarID pgtype.UUID `json:"calendar_id"` + Search pgtype.Text `json:"search"` + Tag pgtype.Text `json:"tag"` + CursorTime pgtype.Timestamptz `json:"cursor_time"` + CursorID pgtype.UUID `json:"cursor_id"` + Lim int32 `json:"lim"` +} + +func (q *Queries) ListEventsInRange(ctx context.Context, arg ListEventsInRangeParams) ([]Event, error) { + rows, err := q.db.Query(ctx, listEventsInRange, + arg.UserID, + arg.RangeEnd, + arg.RangeStart, + arg.CalendarID, + arg.Search, + arg.Tag, + arg.CursorTime, + arg.CursorID, + arg.Lim, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Event{} + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.CalendarID, + &i.Title, + &i.Description, + &i.Location, + &i.StartTime, + &i.EndTime, + &i.Timezone, + &i.AllDay, + &i.RecurrenceRule, + &i.Tags, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecurringEventsByCalendar = `-- name: ListRecurringEventsByCalendar :many +SELECT id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at FROM events +WHERE calendar_id = $1 + AND deleted_at IS NULL + AND recurrence_rule IS NOT NULL + AND start_time <= $2 +ORDER BY start_time ASC +` + +type ListRecurringEventsByCalendarParams struct { + CalendarID pgtype.UUID `json:"calendar_id"` + StartTime pgtype.Timestamptz `json:"start_time"` +} + +func (q *Queries) ListRecurringEventsByCalendar(ctx context.Context, arg ListRecurringEventsByCalendarParams) ([]Event, error) { + rows, err := q.db.Query(ctx, listRecurringEventsByCalendar, arg.CalendarID, arg.StartTime) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Event{} + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.CalendarID, + &i.Title, + &i.Description, + &i.Location, + &i.StartTime, + &i.EndTime, + &i.Timezone, + &i.AllDay, + &i.RecurrenceRule, + &i.Tags, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecurringEventsInRange = `-- name: ListRecurringEventsInRange :many +SELECT e.id, e.calendar_id, e.title, e.description, e.location, e.start_time, e.end_time, e.timezone, e.all_day, e.recurrence_rule, e.tags, e.created_by, e.updated_by, e.created_at, e.updated_at, e.deleted_at FROM events e +JOIN calendar_members cm ON cm.calendar_id = e.calendar_id +WHERE cm.user_id = $1 + AND e.deleted_at IS NULL + AND e.recurrence_rule IS NOT NULL + AND e.start_time <= $2 + AND ($3::UUID IS NULL OR e.calendar_id = $3::UUID) +ORDER BY e.start_time ASC +` + +type ListRecurringEventsInRangeParams struct { + UserID pgtype.UUID `json:"user_id"` + RangeEnd pgtype.Timestamptz `json:"range_end"` + CalendarID pgtype.UUID `json:"calendar_id"` +} + +func (q *Queries) ListRecurringEventsInRange(ctx context.Context, arg ListRecurringEventsInRangeParams) ([]Event, error) { + rows, err := q.db.Query(ctx, listRecurringEventsInRange, arg.UserID, arg.RangeEnd, arg.CalendarID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Event{} + for rows.Next() { + var i Event + if err := rows.Scan( + &i.ID, + &i.CalendarID, + &i.Title, + &i.Description, + &i.Location, + &i.StartTime, + &i.EndTime, + &i.Timezone, + &i.AllDay, + &i.RecurrenceRule, + &i.Tags, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const softDeleteEvent = `-- name: SoftDeleteEvent :exec +UPDATE events SET deleted_at = now(), updated_at = now() +WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteEvent(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteEvent, id) + return err +} + +const softDeleteEventsByCalendar = `-- name: SoftDeleteEventsByCalendar :exec +UPDATE events SET deleted_at = now(), updated_at = now() +WHERE calendar_id = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteEventsByCalendar(ctx context.Context, calendarID pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteEventsByCalendar, calendarID) + return err +} + +const softDeleteEventsByCreator = `-- name: SoftDeleteEventsByCreator :exec +UPDATE events SET deleted_at = now(), updated_at = now() +WHERE created_by = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteEventsByCreator(ctx context.Context, createdBy pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteEventsByCreator, createdBy) + return err +} + +const updateEvent = `-- name: UpdateEvent :one +UPDATE events +SET title = COALESCE($1, title), + description = COALESCE($2, description), + location = COALESCE($3, location), + start_time = COALESCE($4, start_time), + end_time = COALESCE($5, end_time), + timezone = COALESCE($6, timezone), + all_day = COALESCE($7, all_day), + recurrence_rule = $8, + tags = COALESCE($9, tags), + updated_by = $10, + updated_at = now() +WHERE id = $11 AND deleted_at IS NULL +RETURNING id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by, created_at, updated_at, deleted_at +` + +type UpdateEventParams struct { + Title pgtype.Text `json:"title"` + Description pgtype.Text `json:"description"` + Location pgtype.Text `json:"location"` + StartTime pgtype.Timestamptz `json:"start_time"` + EndTime pgtype.Timestamptz `json:"end_time"` + Timezone pgtype.Text `json:"timezone"` + AllDay pgtype.Bool `json:"all_day"` + RecurrenceRule pgtype.Text `json:"recurrence_rule"` + Tags []string `json:"tags"` + UpdatedBy pgtype.UUID `json:"updated_by"` + ID pgtype.UUID `json:"id"` +} + +func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) (Event, error) { + row := q.db.QueryRow(ctx, updateEvent, + arg.Title, + arg.Description, + arg.Location, + arg.StartTime, + arg.EndTime, + arg.Timezone, + arg.AllDay, + arg.RecurrenceRule, + arg.Tags, + arg.UpdatedBy, + arg.ID, + ) + var i Event + err := row.Scan( + &i.ID, + &i.CalendarID, + &i.Title, + &i.Description, + &i.Location, + &i.StartTime, + &i.EndTime, + &i.Timezone, + &i.AllDay, + &i.RecurrenceRule, + &i.Tags, + &i.CreatedBy, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} diff --git a/internal/repository/models.go b/internal/repository/models.go new file mode 100644 index 0000000..0a17829 --- /dev/null +++ b/internal/repository/models.go @@ -0,0 +1,139 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repository + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type ApiKey struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + Scopes []byte `json:"scopes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` +} + +type AuditLog struct { + ID pgtype.UUID `json:"id"` + EntityType string `json:"entity_type"` + EntityID pgtype.UUID `json:"entity_id"` + Action string `json:"action"` + UserID pgtype.UUID `json:"user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type BookingLink struct { + ID pgtype.UUID `json:"id"` + CalendarID pgtype.UUID `json:"calendar_id"` + Token string `json:"token"` + DurationMinutes int32 `json:"duration_minutes"` + BufferMinutes int32 `json:"buffer_minutes"` + Timezone string `json:"timezone"` + WorkingHours []byte `json:"working_hours"` + Active bool `json:"active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Calendar struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + Name string `json:"name"` + Color string `json:"color"` + IsPublic bool `json:"is_public"` + PublicToken pgtype.Text `json:"public_token"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +type CalendarMember struct { + CalendarID pgtype.UUID `json:"calendar_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` +} + +type Contact struct { + ID pgtype.UUID `json:"id"` + OwnerID pgtype.UUID `json:"owner_id"` + FirstName pgtype.Text `json:"first_name"` + LastName pgtype.Text `json:"last_name"` + Email pgtype.Text `json:"email"` + Phone pgtype.Text `json:"phone"` + Company pgtype.Text `json:"company"` + Notes pgtype.Text `json:"notes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +type Event struct { + ID pgtype.UUID `json:"id"` + CalendarID pgtype.UUID `json:"calendar_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Location pgtype.Text `json:"location"` + StartTime pgtype.Timestamptz `json:"start_time"` + EndTime pgtype.Timestamptz `json:"end_time"` + Timezone string `json:"timezone"` + AllDay bool `json:"all_day"` + RecurrenceRule pgtype.Text `json:"recurrence_rule"` + Tags []string `json:"tags"` + CreatedBy pgtype.UUID `json:"created_by"` + UpdatedBy pgtype.UUID `json:"updated_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +type EventAttachment struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + FileUrl string `json:"file_url"` +} + +type EventAttendee struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + UserID pgtype.UUID `json:"user_id"` + Email pgtype.Text `json:"email"` + Status string `json:"status"` +} + +type EventException struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + ExceptionDate pgtype.Date `json:"exception_date"` + Action string `json:"action"` +} + +type EventReminder struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + MinutesBefore int32 `json:"minutes_before"` +} + +type RefreshToken struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Timezone string `json:"timezone"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} diff --git a/internal/repository/refresh_tokens.sql.go b/internal/repository/refresh_tokens.sql.go new file mode 100644 index 0000000..25e6be4 --- /dev/null +++ b/internal/repository/refresh_tokens.sql.go @@ -0,0 +1,83 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: refresh_tokens.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createRefreshToken = `-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, token_hash, expires_at, revoked_at, created_at +` + +type CreateRefreshTokenParams struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) { + row := q.db.QueryRow(ctx, createRefreshToken, + arg.ID, + arg.UserID, + arg.TokenHash, + arg.ExpiresAt, + ) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.TokenHash, + &i.ExpiresAt, + &i.RevokedAt, + &i.CreatedAt, + ) + return i, err +} + +const getRefreshTokenByHash = `-- name: GetRefreshTokenByHash :one +SELECT id, user_id, token_hash, expires_at, revoked_at, created_at +FROM refresh_tokens +WHERE token_hash = $1 AND revoked_at IS NULL +` + +func (q *Queries) GetRefreshTokenByHash(ctx context.Context, tokenHash string) (RefreshToken, error) { + row := q.db.QueryRow(ctx, getRefreshTokenByHash, tokenHash) + var i RefreshToken + err := row.Scan( + &i.ID, + &i.UserID, + &i.TokenHash, + &i.ExpiresAt, + &i.RevokedAt, + &i.CreatedAt, + ) + return i, err +} + +const revokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec +UPDATE refresh_tokens SET revoked_at = now() +WHERE user_id = $1 AND revoked_at IS NULL +` + +func (q *Queries) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, revokeAllUserRefreshTokens, userID) + return err +} + +const revokeRefreshToken = `-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 +` + +func (q *Queries) RevokeRefreshToken(ctx context.Context, tokenHash string) error { + _, err := q.db.Exec(ctx, revokeRefreshToken, tokenHash) + return err +} diff --git a/internal/repository/reminders.sql.go b/internal/repository/reminders.sql.go new file mode 100644 index 0000000..013e0e3 --- /dev/null +++ b/internal/repository/reminders.sql.go @@ -0,0 +1,83 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: reminders.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createReminder = `-- name: CreateReminder :one +INSERT INTO event_reminders (id, event_id, minutes_before) +VALUES ($1, $2, $3) +RETURNING id, event_id, minutes_before +` + +type CreateReminderParams struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` + MinutesBefore int32 `json:"minutes_before"` +} + +func (q *Queries) CreateReminder(ctx context.Context, arg CreateReminderParams) (EventReminder, error) { + row := q.db.QueryRow(ctx, createReminder, arg.ID, arg.EventID, arg.MinutesBefore) + var i EventReminder + err := row.Scan(&i.ID, &i.EventID, &i.MinutesBefore) + return i, err +} + +const deleteReminder = `-- name: DeleteReminder :exec +DELETE FROM event_reminders +WHERE id = $1 AND event_id = $2 +` + +type DeleteReminderParams struct { + ID pgtype.UUID `json:"id"` + EventID pgtype.UUID `json:"event_id"` +} + +func (q *Queries) DeleteReminder(ctx context.Context, arg DeleteReminderParams) error { + _, err := q.db.Exec(ctx, deleteReminder, arg.ID, arg.EventID) + return err +} + +const deleteRemindersByEvent = `-- name: DeleteRemindersByEvent :exec +DELETE FROM event_reminders +WHERE event_id = $1 +` + +func (q *Queries) DeleteRemindersByEvent(ctx context.Context, eventID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteRemindersByEvent, eventID) + return err +} + +const listRemindersByEvent = `-- name: ListRemindersByEvent :many +SELECT id, event_id, minutes_before +FROM event_reminders +WHERE event_id = $1 +ORDER BY minutes_before ASC +` + +func (q *Queries) ListRemindersByEvent(ctx context.Context, eventID pgtype.UUID) ([]EventReminder, error) { + rows, err := q.db.Query(ctx, listRemindersByEvent, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []EventReminder{} + for rows.Next() { + var i EventReminder + if err := rows.Scan(&i.ID, &i.EventID, &i.MinutesBefore); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/repository/users.sql.go b/internal/repository/users.sql.go new file mode 100644 index 0000000..613b6bd --- /dev/null +++ b/internal/repository/users.sql.go @@ -0,0 +1,165 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, email, password_hash, timezone) +VALUES ($1, $2, $3, $4) +RETURNING id, email, password_hash, timezone, is_active, created_at, updated_at +` + +type CreateUserParams struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Timezone string `json:"timezone"` +} + +type CreateUserRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Timezone string `json:"timezone"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { + row := q.db.QueryRow(ctx, createUser, + arg.ID, + arg.Email, + arg.PasswordHash, + arg.Timezone, + ) + var i CreateUserRow + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Timezone, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, timezone, is_active, created_at, updated_at +FROM users +WHERE email = $1 AND deleted_at IS NULL +` + +type GetUserByEmailRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Timezone string `json:"timezone"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i GetUserByEmailRow + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Timezone, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, password_hash, timezone, is_active, created_at, updated_at +FROM users +WHERE id = $1 AND deleted_at IS NULL +` + +type GetUserByIDRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Timezone string `json:"timezone"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Timezone, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const softDeleteUser = `-- name: SoftDeleteUser :exec +UPDATE users SET deleted_at = now(), is_active = false, updated_at = now() +WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteUser(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, softDeleteUser, id) + return err +} + +const updateUser = `-- name: UpdateUser :one +UPDATE users +SET timezone = COALESCE($1::TEXT, timezone), + updated_at = now() +WHERE id = $2 AND deleted_at IS NULL +RETURNING id, email, password_hash, timezone, is_active, created_at, updated_at +` + +type UpdateUserParams struct { + Timezone pgtype.Text `json:"timezone"` + ID pgtype.UUID `json:"id"` +} + +type UpdateUserRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Timezone string `json:"timezone"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, arg.Timezone, arg.ID) + var i UpdateUserRow + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Timezone, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..0fe39a1 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,63 @@ +package scheduler + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/hibiken/asynq" +) + +const TypeReminder = "reminder:send" + +type ReminderPayload struct { + EventID uuid.UUID `json:"event_id"` + ReminderID uuid.UUID `json:"reminder_id"` + UserID uuid.UUID `json:"user_id"` +} + +type Scheduler struct { + client *asynq.Client +} + +func NewScheduler(redisAddr string) *Scheduler { + client := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr}) + return &Scheduler{client: client} +} + +func (s *Scheduler) ScheduleReminder(_ context.Context, eventID, reminderID, userID uuid.UUID, triggerAt time.Time) error { + payload, err := json.Marshal(ReminderPayload{ + EventID: eventID, + ReminderID: reminderID, + UserID: userID, + }) + if err != nil { + return fmt.Errorf("marshal reminder payload: %w", err) + } + + task := asynq.NewTask(TypeReminder, payload) + _, err = s.client.Enqueue(task, + asynq.ProcessAt(triggerAt), + asynq.MaxRetry(5), + asynq.Queue("reminders"), + ) + if err != nil { + return fmt.Errorf("enqueue reminder: %w", err) + } + + return nil +} + +func (s *Scheduler) Close() error { + return s.client.Close() +} + +type NoopScheduler struct{} + +func (NoopScheduler) ScheduleReminder(_ context.Context, _, _, _ uuid.UUID, _ time.Time) error { + return nil +} + +func (NoopScheduler) Close() error { return nil } diff --git a/internal/scheduler/worker.go b/internal/scheduler/worker.go new file mode 100644 index 0000000..25bdedc --- /dev/null +++ b/internal/scheduler/worker.go @@ -0,0 +1,67 @@ +package scheduler + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/hibiken/asynq" +) + +type ReminderWorker struct { + queries *repository.Queries +} + +func NewReminderWorker(queries *repository.Queries) *ReminderWorker { + return &ReminderWorker{queries: queries} +} + +func (w *ReminderWorker) HandleReminderTask(ctx context.Context, t *asynq.Task) error { + var payload ReminderPayload + if err := json.Unmarshal(t.Payload(), &payload); err != nil { + return fmt.Errorf("unmarshal payload: %w", err) + } + + ev, err := w.queries.GetEventByID(ctx, utils.ToPgUUID(payload.EventID)) + if err != nil { + return fmt.Errorf("get event: %w", err) + } + + if ev.DeletedAt.Valid { + log.Printf("reminder skipped: event %s deleted", payload.EventID) + return nil + } + + log.Printf("reminder triggered: event=%s user=%s title=%s", + payload.EventID, payload.UserID, ev.Title) + + return nil +} + +func StartWorker(redisAddr string, worker *ReminderWorker) *asynq.Server { + srv := asynq.NewServer( + asynq.RedisClientOpt{Addr: redisAddr}, + asynq.Config{ + Concurrency: 10, + Queues: map[string]int{ + "reminders": 6, + "default": 3, + }, + RetryDelayFunc: asynq.DefaultRetryDelayFunc, + }, + ) + + mux := asynq.NewServeMux() + mux.HandleFunc(TypeReminder, worker.HandleReminderTask) + + go func() { + if err := srv.Run(mux); err != nil { + log.Printf("asynq worker error: %v", err) + } + }() + + return srv +} diff --git a/internal/service/apikey.go b/internal/service/apikey.go new file mode 100644 index 0000000..a5c1c69 --- /dev/null +++ b/internal/service/apikey.go @@ -0,0 +1,84 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + + "github.com/calendarapi/internal/middleware" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" +) + +type APIKeyService struct { + queries *repository.Queries +} + +func NewAPIKeyService(queries *repository.Queries) *APIKeyService { + return &APIKeyService{queries: queries} +} + +func (s *APIKeyService) Create(ctx context.Context, userID uuid.UUID, name string, scopes map[string][]string) (*models.APIKeyResponse, error) { + if name == "" { + return nil, models.NewValidationError("name is required") + } + + rawToken := make([]byte, 32) + if _, err := rand.Read(rawToken); err != nil { + return nil, models.ErrInternal + } + token := hex.EncodeToString(rawToken) + hash := middleware.SHA256Hash(token) + + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return nil, models.ErrInternal + } + + keyID := uuid.New() + key, err := s.queries.CreateAPIKey(ctx, repository.CreateAPIKeyParams{ + ID: utils.ToPgUUID(keyID), + UserID: utils.ToPgUUID(userID), + Name: name, + KeyHash: hash, + Scopes: scopesJSON, + }) + if err != nil { + return nil, models.ErrInternal + } + + return &models.APIKeyResponse{ + ID: utils.FromPgUUID(key.ID), + Name: key.Name, + CreatedAt: utils.FromPgTimestamptz(key.CreatedAt), + Token: token, + }, nil +} + +func (s *APIKeyService) List(ctx context.Context, userID uuid.UUID) ([]models.APIKeyResponse, error) { + keys, err := s.queries.ListAPIKeysByUser(ctx, utils.ToPgUUID(userID)) + if err != nil { + return nil, models.ErrInternal + } + + result := make([]models.APIKeyResponse, len(keys)) + for i, k := range keys { + result[i] = models.APIKeyResponse{ + ID: utils.FromPgUUID(k.ID), + Name: k.Name, + CreatedAt: utils.FromPgTimestamptz(k.CreatedAt), + RevokedAt: utils.FromPgTimestamptzPtr(k.RevokedAt), + } + } + return result, nil +} + +func (s *APIKeyService) Revoke(ctx context.Context, userID uuid.UUID, keyID uuid.UUID) error { + return s.queries.RevokeAPIKey(ctx, repository.RevokeAPIKeyParams{ + ID: utils.ToPgUUID(keyID), + UserID: utils.ToPgUUID(userID), + }) +} diff --git a/internal/service/attendee.go b/internal/service/attendee.go new file mode 100644 index 0000000..ee4911d --- /dev/null +++ b/internal/service/attendee.go @@ -0,0 +1,132 @@ +package service + +import ( + "context" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type AttendeeService struct { + queries *repository.Queries + calendar *CalendarService +} + +func NewAttendeeService(queries *repository.Queries, calendar *CalendarService) *AttendeeService { + return &AttendeeService{queries: queries, calendar: calendar} +} + +func (s *AttendeeService) AddAttendees(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendees []AddAttendeeRequest) (*models.Event, error) { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return nil, err + } + if role != "owner" && role != "editor" { + return nil, models.ErrForbidden + } + + for _, a := range attendees { + _, err := s.queries.CreateAttendee(ctx, repository.CreateAttendeeParams{ + ID: utils.ToPgUUID(uuid.New()), + EventID: utils.ToPgUUID(eventID), + UserID: optionalPgUUID(a.UserID), + Email: utils.ToPgTextPtr(a.Email), + }) + if err != nil { + return nil, models.ErrInternal + } + } + + reminders, _ := loadRemindersHelper(ctx, s.queries, eventID) + atts, _ := loadAttendeesHelper(ctx, s.queries, eventID) + attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID) + return eventFromDB(ev, reminders, atts, attachments), nil +} + +func (s *AttendeeService) UpdateStatus(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendeeID uuid.UUID, status string) (*models.Event, error) { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return nil, err + } + + att, err := s.queries.GetAttendeeByID(ctx, utils.ToPgUUID(attendeeID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + isOrganizer := role == "owner" || role == "editor" + isOwnAttendee := att.UserID.Valid && utils.FromPgUUID(att.UserID) == userID + if !isOrganizer && !isOwnAttendee { + return nil, models.ErrForbidden + } + + if status != "pending" && status != "accepted" && status != "declined" && status != "tentative" { + return nil, models.NewValidationError("status must be pending, accepted, declined, or tentative") + } + + _, err = s.queries.UpdateAttendeeStatus(ctx, repository.UpdateAttendeeStatusParams{ + ID: utils.ToPgUUID(attendeeID), + Status: status, + }) + if err != nil { + return nil, models.ErrInternal + } + + reminders, _ := loadRemindersHelper(ctx, s.queries, eventID) + atts, _ := loadAttendeesHelper(ctx, s.queries, eventID) + attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID) + return eventFromDB(ev, reminders, atts, attachments), nil +} + +func (s *AttendeeService) DeleteAttendee(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, attendeeID uuid.UUID) error { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return models.ErrNotFound + } + return models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return err + } + if role != "owner" && role != "editor" { + return models.ErrForbidden + } + + return s.queries.DeleteAttendee(ctx, repository.DeleteAttendeeParams{ + ID: utils.ToPgUUID(attendeeID), + EventID: utils.ToPgUUID(eventID), + }) +} + +type AddAttendeeRequest struct { + UserID *uuid.UUID + Email *string +} diff --git a/internal/service/audit.go b/internal/service/audit.go new file mode 100644 index 0000000..50c6e18 --- /dev/null +++ b/internal/service/audit.go @@ -0,0 +1,30 @@ +package service + +import ( + "context" + "log" + + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" +) + +type AuditService struct { + queries *repository.Queries +} + +func NewAuditService(queries *repository.Queries) *AuditService { + return &AuditService{queries: queries} +} + +func (s *AuditService) Log(ctx context.Context, entityType string, entityID uuid.UUID, action string, userID uuid.UUID) { + err := s.queries.CreateAuditLog(ctx, repository.CreateAuditLogParams{ + EntityType: entityType, + EntityID: utils.ToPgUUID(entityID), + Action: action, + UserID: utils.ToPgUUID(userID), + }) + if err != nil { + log.Printf("audit log failed: entity=%s id=%s action=%s err=%v", entityType, entityID, action, err) + } +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..365776b --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,256 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "time" + + "github.com/calendarapi/internal/auth" + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +type AuthService struct { + pool *pgxpool.Pool + queries *repository.Queries + jwt *auth.JWTManager + audit *AuditService +} + +func NewAuthService(pool *pgxpool.Pool, queries *repository.Queries, jwt *auth.JWTManager, audit *AuditService) *AuthService { + return &AuthService{pool: pool, queries: queries, jwt: jwt, audit: audit} +} + +func (s *AuthService) Register(ctx context.Context, email, password, timezone string) (*models.AuthTokens, error) { + email = utils.NormalizeEmail(email) + if err := utils.ValidateEmail(email); err != nil { + return nil, err + } + if err := utils.ValidatePassword(password); err != nil { + return nil, err + } + if timezone == "" { + timezone = "UTC" + } + if err := utils.ValidateTimezone(timezone); err != nil { + return nil, err + } + + _, err := s.queries.GetUserByEmail(ctx, email) + if err == nil { + return nil, models.NewConflictError("email already registered") + } + if err != pgx.ErrNoRows { + return nil, models.ErrInternal + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return nil, models.ErrInternal + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, models.ErrInternal + } + defer tx.Rollback(ctx) + qtx := s.queries.WithTx(tx) + + userID := uuid.New() + dbUser, err := qtx.CreateUser(ctx, repository.CreateUserParams{ + ID: utils.ToPgUUID(userID), + Email: email, + PasswordHash: string(hash), + Timezone: timezone, + }) + if err != nil { + return nil, models.ErrInternal + } + + calID := uuid.New() + _, err = qtx.CreateCalendar(ctx, repository.CreateCalendarParams{ + ID: utils.ToPgUUID(calID), + OwnerID: utils.ToPgUUID(userID), + Name: "My Calendar", + Color: "#3B82F6", + IsPublic: false, + }) + if err != nil { + return nil, models.ErrInternal + } + + err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{ + CalendarID: utils.ToPgUUID(calID), + UserID: utils.ToPgUUID(userID), + Role: "owner", + }) + if err != nil { + return nil, models.ErrInternal + } + + if err := tx.Commit(ctx); err != nil { + return nil, models.ErrInternal + } + + accessToken, err := s.jwt.GenerateAccessToken(userID) + if err != nil { + return nil, models.ErrInternal + } + + refreshToken, err := s.jwt.GenerateRefreshToken(userID) + if err != nil { + return nil, models.ErrInternal + } + + rtHash := hashToken(refreshToken) + _, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{ + ID: utils.ToPgUUID(uuid.New()), + UserID: utils.ToPgUUID(userID), + TokenHash: rtHash, + ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)), + }) + if err != nil { + return nil, models.ErrInternal + } + + user := userFromCreateRow(dbUser) + return &models.AuthTokens{User: user, AccessToken: accessToken, RefreshToken: refreshToken}, nil +} + +func (s *AuthService) Login(ctx context.Context, email, password string) (*models.AuthTokens, error) { + email = utils.NormalizeEmail(email) + + dbUser, err := s.queries.GetUserByEmail(ctx, email) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrAuthInvalid + } + return nil, models.ErrInternal + } + + if err := bcrypt.CompareHashAndPassword([]byte(dbUser.PasswordHash), []byte(password)); err != nil { + return nil, models.ErrAuthInvalid + } + + userID := utils.FromPgUUID(dbUser.ID) + accessToken, err := s.jwt.GenerateAccessToken(userID) + if err != nil { + return nil, models.ErrInternal + } + + refreshToken, err := s.jwt.GenerateRefreshToken(userID) + if err != nil { + return nil, models.ErrInternal + } + + rtHash := hashToken(refreshToken) + _, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{ + ID: utils.ToPgUUID(uuid.New()), + UserID: utils.ToPgUUID(userID), + TokenHash: rtHash, + ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)), + }) + if err != nil { + return nil, models.ErrInternal + } + + user := userFromEmailRow(dbUser) + return &models.AuthTokens{User: user, AccessToken: accessToken, RefreshToken: refreshToken}, nil +} + +func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*models.TokenPair, error) { + rtHash := hashToken(refreshTokenStr) + rt, err := s.queries.GetRefreshTokenByHash(ctx, rtHash) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrAuthInvalid + } + return nil, models.ErrInternal + } + + if utils.FromPgTimestamptz(rt.ExpiresAt).Before(time.Now()) { + return nil, models.ErrAuthInvalid + } + + _ = s.queries.RevokeRefreshToken(ctx, rtHash) + + userID := utils.FromPgUUID(rt.UserID) + accessToken, err := s.jwt.GenerateAccessToken(userID) + if err != nil { + return nil, models.ErrInternal + } + + newRefresh, err := s.jwt.GenerateRefreshToken(userID) + if err != nil { + return nil, models.ErrInternal + } + + newHash := hashToken(newRefresh) + _, err = s.queries.CreateRefreshToken(ctx, repository.CreateRefreshTokenParams{ + ID: utils.ToPgUUID(uuid.New()), + UserID: utils.ToPgUUID(userID), + TokenHash: newHash, + ExpiresAt: utils.ToPgTimestamptz(time.Now().Add(auth.RefreshTokenDuration)), + }) + if err != nil { + return nil, models.ErrInternal + } + + return &models.TokenPair{AccessToken: accessToken, RefreshToken: newRefresh}, nil +} + +func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error { + rtHash := hashToken(refreshTokenStr) + return s.queries.RevokeRefreshToken(ctx, rtHash) +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +func userFromCreateRow(u repository.CreateUserRow) models.User { + return models.User{ + ID: utils.FromPgUUID(u.ID), + Email: u.Email, + Timezone: u.Timezone, + CreatedAt: utils.FromPgTimestamptz(u.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt), + } +} + +func userFromEmailRow(u repository.GetUserByEmailRow) models.User { + return models.User{ + ID: utils.FromPgUUID(u.ID), + Email: u.Email, + Timezone: u.Timezone, + CreatedAt: utils.FromPgTimestamptz(u.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt), + } +} + +func userFromIDRow(u repository.GetUserByIDRow) models.User { + return models.User{ + ID: utils.FromPgUUID(u.ID), + Email: u.Email, + Timezone: u.Timezone, + CreatedAt: utils.FromPgTimestamptz(u.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt), + } +} + +func userFromUpdateRow(u repository.UpdateUserRow) models.User { + return models.User{ + ID: utils.FromPgUUID(u.ID), + Email: u.Email, + Timezone: u.Timezone, + CreatedAt: utils.FromPgTimestamptz(u.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(u.UpdatedAt), + } +} diff --git a/internal/service/availability.go b/internal/service/availability.go new file mode 100644 index 0000000..68d21ad --- /dev/null +++ b/internal/service/availability.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "sort" + "time" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" +) + +type AvailabilityService struct { + queries *repository.Queries + calendar *CalendarService + event *EventService +} + +func NewAvailabilityService(queries *repository.Queries, calendar *CalendarService, event *EventService) *AvailabilityService { + return &AvailabilityService{queries: queries, calendar: calendar, event: event} +} + +func (s *AvailabilityService) GetBusyBlocks(ctx context.Context, userID uuid.UUID, calendarID uuid.UUID, rangeStart, rangeEnd time.Time) (*models.AvailabilityResponse, error) { + if _, err := s.calendar.GetRole(ctx, calendarID, userID); err != nil { + return nil, err + } + + pgCalID := utils.ToPgUUID(calendarID) + events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{ + CalendarID: pgCalID, + EndTime: utils.ToPgTimestamptz(rangeStart), + StartTime: utils.ToPgTimestamptz(rangeEnd), + }) + if err != nil { + return nil, models.ErrInternal + } + + recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{ + CalendarID: pgCalID, + StartTime: utils.ToPgTimestamptz(rangeEnd), + }) + if err != nil { + return nil, models.ErrInternal + } + + var busy []models.BusyBlock + for _, ev := range events { + if ev.RecurrenceRule.Valid { + continue + } + busy = append(busy, models.BusyBlock{ + Start: utils.FromPgTimestamptz(ev.StartTime), + End: utils.FromPgTimestamptz(ev.EndTime), + EventID: utils.FromPgUUID(ev.ID), + }) + } + + for _, ev := range recurring { + occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd) + for _, occ := range occs { + if occ.OccurrenceStartTime != nil { + busy = append(busy, models.BusyBlock{ + Start: *occ.OccurrenceStartTime, + End: *occ.OccurrenceEndTime, + EventID: occ.ID, + }) + } + } + } + + sort.Slice(busy, func(i, j int) bool { + return busy[i].Start.Before(busy[j].Start) + }) + + if busy == nil { + busy = []models.BusyBlock{} + } + + return &models.AvailabilityResponse{ + CalendarID: calendarID, + RangeStart: rangeStart, + RangeEnd: rangeEnd, + Busy: busy, + }, nil +} diff --git a/internal/service/booking.go b/internal/service/booking.go new file mode 100644 index 0000000..3842993 --- /dev/null +++ b/internal/service/booking.go @@ -0,0 +1,264 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "time" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type BookingService struct { + pool *pgxpool.Pool + queries *repository.Queries + calendar *CalendarService + event *EventService +} + +func NewBookingService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, event *EventService) *BookingService { + return &BookingService{pool: pool, queries: queries, calendar: calendar, event: event} +} + +func (s *BookingService) CreateLink(ctx context.Context, userID uuid.UUID, calID uuid.UUID, config models.BookingConfig) (*models.BookingLink, error) { + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return nil, err + } + if role != "owner" { + return nil, models.ErrForbidden + } + + tokenBytes := make([]byte, 16) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, models.ErrInternal + } + token := hex.EncodeToString(tokenBytes) + + whJSON, err := json.Marshal(config.WorkingHours) + if err != nil { + return nil, models.ErrInternal + } + + _, err = s.queries.CreateBookingLink(ctx, repository.CreateBookingLinkParams{ + ID: utils.ToPgUUID(uuid.New()), + CalendarID: utils.ToPgUUID(calID), + Token: token, + DurationMinutes: int32(config.DurationMinutes), + BufferMinutes: int32(config.BufferMinutes), + Timezone: config.Timezone, + WorkingHours: whJSON, + Active: config.Active, + }) + if err != nil { + return nil, models.ErrInternal + } + + return &models.BookingLink{ + Token: token, + PublicURL: fmt.Sprintf("/booking/%s", token), + Settings: config, + }, nil +} + +func (s *BookingService) GetAvailability(ctx context.Context, token string, rangeStart, rangeEnd time.Time) (*models.BookingAvailability, error) { + bl, err := s.queries.GetBookingLinkByToken(ctx, token) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + if !bl.Active { + return nil, models.NewNotFoundError("booking link is not active") + } + + var workingHours map[string][]models.Slot + if err := json.Unmarshal(bl.WorkingHours, &workingHours); err != nil { + return nil, models.ErrInternal + } + + loc, err := time.LoadLocation(bl.Timezone) + if err != nil { + loc = time.UTC + } + + calID := utils.FromPgUUID(bl.CalendarID) + events, err := s.queries.ListEventsByCalendarInRange(ctx, repository.ListEventsByCalendarInRangeParams{ + CalendarID: bl.CalendarID, + EndTime: utils.ToPgTimestamptz(rangeStart), + StartTime: utils.ToPgTimestamptz(rangeEnd), + }) + if err != nil { + return nil, models.ErrInternal + } + + recurring, err := s.queries.ListRecurringEventsByCalendar(ctx, repository.ListRecurringEventsByCalendarParams{ + CalendarID: bl.CalendarID, + StartTime: utils.ToPgTimestamptz(rangeEnd), + }) + if err != nil { + return nil, models.ErrInternal + } + + var busyBlocks []models.TimeSlot + for _, ev := range events { + if ev.RecurrenceRule.Valid { + continue + } + start := utils.FromPgTimestamptz(ev.StartTime) + end := utils.FromPgTimestamptz(ev.EndTime) + buf := time.Duration(bl.BufferMinutes) * time.Minute + busyBlocks = append(busyBlocks, models.TimeSlot{ + Start: start.Add(-buf), + End: end.Add(buf), + }) + } + + for _, ev := range recurring { + occs := s.event.expandRecurrence(ev, rangeStart, rangeEnd) + for _, occ := range occs { + if occ.OccurrenceStartTime != nil { + buf := time.Duration(bl.BufferMinutes) * time.Minute + busyBlocks = append(busyBlocks, models.TimeSlot{ + Start: occ.OccurrenceStartTime.Add(-buf), + End: occ.OccurrenceEndTime.Add(buf), + }) + } + } + } + + _ = calID + duration := time.Duration(bl.DurationMinutes) * time.Minute + + dayNames := []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} + var slots []models.TimeSlot + + for d := rangeStart; d.Before(rangeEnd); d = d.Add(24 * time.Hour) { + localDay := d.In(loc) + dayName := dayNames[localDay.Weekday()] + windows, ok := workingHours[dayName] + if !ok || len(windows) == 0 { + continue + } + + for _, w := range windows { + wStart, err1 := time.Parse("15:04", w.Start) + wEnd, err2 := time.Parse("15:04", w.End) + if err1 != nil || err2 != nil { + continue + } + + windowStart := time.Date(localDay.Year(), localDay.Month(), localDay.Day(), + wStart.Hour(), wStart.Minute(), 0, 0, loc).UTC() + windowEnd := time.Date(localDay.Year(), localDay.Month(), localDay.Day(), + wEnd.Hour(), wEnd.Minute(), 0, 0, loc).UTC() + + for slotStart := windowStart; slotStart.Add(duration).Before(windowEnd) || slotStart.Add(duration).Equal(windowEnd); slotStart = slotStart.Add(duration) { + slotEnd := slotStart.Add(duration) + if !isConflict(slotStart, slotEnd, busyBlocks) { + slots = append(slots, models.TimeSlot{Start: slotStart, End: slotEnd}) + } + } + } + } + + sort.Slice(slots, func(i, j int) bool { + return slots[i].Start.Before(slots[j].Start) + }) + + if slots == nil { + slots = []models.TimeSlot{} + } + + return &models.BookingAvailability{ + Token: token, + Timezone: bl.Timezone, + DurationMinutes: int(bl.DurationMinutes), + Slots: slots, + }, nil +} + +func (s *BookingService) Reserve(ctx context.Context, token string, name, email string, slotStart, slotEnd time.Time, notes *string) (*models.Event, error) { + bl, err := s.queries.GetBookingLinkByToken(ctx, token) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + if !bl.Active { + return nil, models.NewNotFoundError("booking link is not active") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, models.ErrInternal + } + defer tx.Rollback(ctx) + qtx := s.queries.WithTx(tx) + + overlap, err := qtx.CheckEventOverlapForUpdate(ctx, repository.CheckEventOverlapForUpdateParams{ + CalendarID: bl.CalendarID, + EndTime: utils.ToPgTimestamptz(slotStart), + StartTime: utils.ToPgTimestamptz(slotEnd), + }) + if err != nil { + return nil, models.ErrInternal + } + if overlap { + return nil, models.NewConflictError("slot no longer available") + } + + cal, err := s.queries.GetCalendarByID(ctx, bl.CalendarID) + if err != nil { + return nil, models.ErrInternal + } + + title := fmt.Sprintf("Booking: %s", name) + desc := fmt.Sprintf("Booked by %s (%s)", name, email) + if notes != nil && *notes != "" { + desc += "\nNotes: " + *notes + } + + eventID := uuid.New() + ownerID := utils.FromPgUUID(cal.OwnerID) + ev, err := qtx.CreateEvent(ctx, repository.CreateEventParams{ + ID: utils.ToPgUUID(eventID), + CalendarID: bl.CalendarID, + Title: title, + Description: utils.ToPgText(desc), + StartTime: utils.ToPgTimestamptz(slotStart.UTC()), + EndTime: utils.ToPgTimestamptz(slotEnd.UTC()), + Timezone: bl.Timezone, + Tags: []string{"booking"}, + CreatedBy: utils.ToPgUUID(ownerID), + UpdatedBy: utils.ToPgUUID(ownerID), + }) + if err != nil { + return nil, models.ErrInternal + } + + if err := tx.Commit(ctx); err != nil { + return nil, models.ErrInternal + } + + return eventFromDB(ev, []models.Reminder{}, []models.Attendee{}, []models.Attachment{}), nil +} + +func isConflict(start, end time.Time, busy []models.TimeSlot) bool { + for _, b := range busy { + if start.Before(b.End) && end.After(b.Start) { + return true + } + } + return false +} diff --git a/internal/service/calendar.go b/internal/service/calendar.go new file mode 100644 index 0000000..81ab8b5 --- /dev/null +++ b/internal/service/calendar.go @@ -0,0 +1,318 @@ +package service + +import ( + "context" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type CalendarService struct { + pool *pgxpool.Pool + queries *repository.Queries + audit *AuditService +} + +func NewCalendarService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *CalendarService { + return &CalendarService{pool: pool, queries: queries, audit: audit} +} + +func (s *CalendarService) Create(ctx context.Context, userID uuid.UUID, name, color string) (*models.Calendar, error) { + if err := utils.ValidateCalendarName(name); err != nil { + return nil, err + } + if err := utils.ValidateColor(color); err != nil { + return nil, err + } + if color == "" { + color = "#3B82F6" + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, models.ErrInternal + } + defer tx.Rollback(ctx) + qtx := s.queries.WithTx(tx) + + calID := uuid.New() + cal, err := qtx.CreateCalendar(ctx, repository.CreateCalendarParams{ + ID: utils.ToPgUUID(calID), + OwnerID: utils.ToPgUUID(userID), + Name: name, + Color: color, + IsPublic: false, + }) + if err != nil { + return nil, models.ErrInternal + } + + err = qtx.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{ + CalendarID: utils.ToPgUUID(calID), + UserID: utils.ToPgUUID(userID), + Role: "owner", + }) + if err != nil { + return nil, models.ErrInternal + } + + if err := tx.Commit(ctx); err != nil { + return nil, models.ErrInternal + } + + s.audit.Log(ctx, "calendar", calID, "CREATE_CALENDAR", userID) + + return &models.Calendar{ + ID: utils.FromPgUUID(cal.ID), + Name: cal.Name, + Color: cal.Color, + IsPublic: cal.IsPublic, + Role: "owner", + CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt), + }, nil +} + +func (s *CalendarService) List(ctx context.Context, userID uuid.UUID) ([]models.Calendar, error) { + rows, err := s.queries.ListCalendarsByUser(ctx, utils.ToPgUUID(userID)) + if err != nil { + return nil, models.ErrInternal + } + + calendars := make([]models.Calendar, len(rows)) + for i, r := range rows { + calendars[i] = models.Calendar{ + ID: utils.FromPgUUID(r.ID), + Name: r.Name, + Color: r.Color, + IsPublic: r.IsPublic, + Role: r.Role, + CreatedAt: utils.FromPgTimestamptz(r.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(r.UpdatedAt), + } + } + return calendars, nil +} + +func (s *CalendarService) Get(ctx context.Context, userID uuid.UUID, calID uuid.UUID) (*models.Calendar, error) { + role, err := s.getRole(ctx, calID, userID) + if err != nil { + return nil, err + } + + cal, err := s.queries.GetCalendarByID(ctx, utils.ToPgUUID(calID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + return &models.Calendar{ + ID: utils.FromPgUUID(cal.ID), + Name: cal.Name, + Color: cal.Color, + IsPublic: cal.IsPublic, + Role: role, + CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt), + }, nil +} + +func (s *CalendarService) Update(ctx context.Context, userID uuid.UUID, calID uuid.UUID, name, color *string, isPublic *bool) (*models.Calendar, error) { + role, err := s.getRole(ctx, calID, userID) + if err != nil { + return nil, err + } + + if role != "owner" && role != "editor" { + return nil, models.ErrForbidden + } + if isPublic != nil && role != "owner" { + return nil, models.NewForbiddenError("only owner can change is_public") + } + + if name != nil { + if err := utils.ValidateCalendarName(*name); err != nil { + return nil, err + } + } + if color != nil { + if err := utils.ValidateColor(*color); err != nil { + return nil, err + } + } + + var pgPublic pgtype.Bool + if isPublic != nil { + pgPublic = pgtype.Bool{Bool: *isPublic, Valid: true} + } + + cal, err := s.queries.UpdateCalendar(ctx, repository.UpdateCalendarParams{ + ID: utils.ToPgUUID(calID), + Name: utils.ToPgTextPtr(name), + Color: utils.ToPgTextPtr(color), + IsPublic: pgPublic, + }) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + s.audit.Log(ctx, "calendar", calID, "UPDATE_CALENDAR", userID) + + return &models.Calendar{ + ID: utils.FromPgUUID(cal.ID), + Name: cal.Name, + Color: cal.Color, + IsPublic: cal.IsPublic, + Role: role, + CreatedAt: utils.FromPgTimestamptz(cal.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(cal.UpdatedAt), + }, nil +} + +func (s *CalendarService) Delete(ctx context.Context, userID uuid.UUID, calID uuid.UUID) error { + role, err := s.getRole(ctx, calID, userID) + if err != nil { + return err + } + if role != "owner" { + return models.ErrForbidden + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return models.ErrInternal + } + defer tx.Rollback(ctx) + qtx := s.queries.WithTx(tx) + + pgCalID := utils.ToPgUUID(calID) + if err := qtx.SoftDeleteEventsByCalendar(ctx, pgCalID); err != nil { + return models.ErrInternal + } + if err := qtx.SoftDeleteCalendar(ctx, pgCalID); err != nil { + return models.ErrInternal + } + + if err := tx.Commit(ctx); err != nil { + return models.ErrInternal + } + + s.audit.Log(ctx, "calendar", calID, "DELETE_CALENDAR", userID) + return nil +} + +func (s *CalendarService) Share(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetEmail, role string) error { + ownerRole, err := s.getRole(ctx, calID, ownerID) + if err != nil { + return err + } + if ownerRole != "owner" { + return models.ErrForbidden + } + if role != "editor" && role != "viewer" { + return models.NewValidationError("role must be editor or viewer") + } + + targetUser, err := s.queries.GetUserByEmail(ctx, utils.NormalizeEmail(targetEmail)) + if err != nil { + if err == pgx.ErrNoRows { + return models.NewNotFoundError("user not found") + } + return models.ErrInternal + } + + targetID := utils.FromPgUUID(targetUser.ID) + if targetID == ownerID { + return models.NewValidationError("cannot share with yourself") + } + + err = s.queries.UpsertCalendarMember(ctx, repository.UpsertCalendarMemberParams{ + CalendarID: utils.ToPgUUID(calID), + UserID: utils.ToPgUUID(targetID), + Role: role, + }) + if err != nil { + return models.ErrInternal + } + + s.audit.Log(ctx, "calendar", calID, "SHARE_CALENDAR", ownerID) + return nil +} + +func (s *CalendarService) ListMembers(ctx context.Context, userID uuid.UUID, calID uuid.UUID) ([]models.CalendarMember, error) { + if _, err := s.getRole(ctx, calID, userID); err != nil { + return nil, err + } + + rows, err := s.queries.ListCalendarMembers(ctx, utils.ToPgUUID(calID)) + if err != nil { + return nil, models.ErrInternal + } + + members := make([]models.CalendarMember, len(rows)) + for i, r := range rows { + members[i] = models.CalendarMember{ + UserID: utils.FromPgUUID(r.UserID), + Email: r.Email, + Role: r.Role, + } + } + return members, nil +} + +func (s *CalendarService) RemoveMember(ctx context.Context, ownerID uuid.UUID, calID uuid.UUID, targetUserID uuid.UUID) error { + role, err := s.getRole(ctx, calID, ownerID) + if err != nil { + return err + } + if role != "owner" { + return models.ErrForbidden + } + + targetRole, err := s.getRole(ctx, calID, targetUserID) + if err != nil { + return err + } + if targetRole == "owner" { + return models.NewValidationError("cannot remove owner") + } + + err = s.queries.DeleteCalendarMember(ctx, repository.DeleteCalendarMemberParams{ + CalendarID: utils.ToPgUUID(calID), + UserID: utils.ToPgUUID(targetUserID), + }) + if err != nil { + return models.ErrInternal + } + + s.audit.Log(ctx, "calendar", calID, "REMOVE_MEMBER", ownerID) + return nil +} + +func (s *CalendarService) GetRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) { + return s.getRole(ctx, calID, userID) +} + +func (s *CalendarService) getRole(ctx context.Context, calID uuid.UUID, userID uuid.UUID) (string, error) { + role, err := s.queries.GetCalendarMemberRole(ctx, repository.GetCalendarMemberRoleParams{ + CalendarID: utils.ToPgUUID(calID), + UserID: utils.ToPgUUID(userID), + }) + if err != nil { + if err == pgx.ErrNoRows { + return "", models.ErrNotFound + } + return "", models.ErrInternal + } + return role, nil +} diff --git a/internal/service/contact.go b/internal/service/contact.go new file mode 100644 index 0000000..97082e8 --- /dev/null +++ b/internal/service/contact.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type ContactService struct { + queries *repository.Queries + audit *AuditService +} + +func NewContactService(queries *repository.Queries, audit *AuditService) *ContactService { + return &ContactService{queries: queries, audit: audit} +} + +func (s *ContactService) Create(ctx context.Context, userID uuid.UUID, req CreateContactRequest) (*models.Contact, error) { + if req.FirstName == nil && req.LastName == nil && req.Email == nil && req.Phone == nil { + return nil, models.NewValidationError("at least one identifying field required") + } + if req.Email != nil { + if err := utils.ValidateEmail(*req.Email); err != nil { + return nil, err + } + } + + id := uuid.New() + c, err := s.queries.CreateContact(ctx, repository.CreateContactParams{ + ID: utils.ToPgUUID(id), + OwnerID: utils.ToPgUUID(userID), + FirstName: utils.ToPgTextPtr(req.FirstName), + LastName: utils.ToPgTextPtr(req.LastName), + Email: utils.ToPgTextPtr(req.Email), + Phone: utils.ToPgTextPtr(req.Phone), + Company: utils.ToPgTextPtr(req.Company), + Notes: utils.ToPgTextPtr(req.Notes), + }) + if err != nil { + return nil, models.ErrInternal + } + + s.audit.Log(ctx, "contact", id, "CREATE_CONTACT", userID) + return contactFromDB(c), nil +} + +func (s *ContactService) Get(ctx context.Context, userID uuid.UUID, contactID uuid.UUID) (*models.Contact, error) { + c, err := s.queries.GetContactByID(ctx, repository.GetContactByIDParams{ + ID: utils.ToPgUUID(contactID), + OwnerID: utils.ToPgUUID(userID), + }) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + return contactFromDB(c), nil +} + +func (s *ContactService) List(ctx context.Context, userID uuid.UUID, search *string, limit int, cursor string) ([]models.Contact, *string, error) { + lim := utils.ClampLimit(limit) + + var cursorTime, cursorID interface{} + p := repository.ListContactsParams{ + OwnerID: utils.ToPgUUID(userID), + Search: utils.ToPgTextPtr(search), + Lim: lim + 1, + } + + if cursor != "" { + ct, cid, err := utils.ParseCursor(cursor) + if err != nil { + return nil, nil, models.NewValidationError("invalid cursor") + } + p.CursorTime = utils.ToPgTimestamptzPtr(ct) + p.CursorID = utils.ToPgUUID(*cid) + cursorTime = ct + cursorID = cid + } + _ = cursorTime + _ = cursorID + + rows, err := s.queries.ListContacts(ctx, p) + if err != nil { + return nil, nil, models.ErrInternal + } + + contacts := make([]models.Contact, 0, len(rows)) + for _, r := range rows { + contacts = append(contacts, *contactFromDB(r)) + } + + if int32(len(contacts)) > lim { + contacts = contacts[:lim] + last := contacts[len(contacts)-1] + c := utils.EncodeCursor(last.CreatedAt, last.ID) + return contacts, &c, nil + } + + return contacts, nil, nil +} + +func (s *ContactService) Update(ctx context.Context, userID uuid.UUID, contactID uuid.UUID, req UpdateContactRequest) (*models.Contact, error) { + c, err := s.queries.UpdateContact(ctx, repository.UpdateContactParams{ + ID: utils.ToPgUUID(contactID), + OwnerID: utils.ToPgUUID(userID), + FirstName: utils.ToPgTextPtr(req.FirstName), + LastName: utils.ToPgTextPtr(req.LastName), + Email: utils.ToPgTextPtr(req.Email), + Phone: utils.ToPgTextPtr(req.Phone), + Company: utils.ToPgTextPtr(req.Company), + Notes: utils.ToPgTextPtr(req.Notes), + }) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + s.audit.Log(ctx, "contact", contactID, "UPDATE_CONTACT", userID) + return contactFromDB(c), nil +} + +func (s *ContactService) Delete(ctx context.Context, userID uuid.UUID, contactID uuid.UUID) error { + err := s.queries.SoftDeleteContact(ctx, repository.SoftDeleteContactParams{ + ID: utils.ToPgUUID(contactID), + OwnerID: utils.ToPgUUID(userID), + }) + if err != nil { + return models.ErrInternal + } + s.audit.Log(ctx, "contact", contactID, "DELETE_CONTACT", userID) + return nil +} + +func contactFromDB(v repository.Contact) *models.Contact { + return &models.Contact{ + ID: utils.FromPgUUID(v.ID), + FirstName: utils.FromPgText(v.FirstName), + LastName: utils.FromPgText(v.LastName), + Email: utils.FromPgText(v.Email), + Phone: utils.FromPgText(v.Phone), + Company: utils.FromPgText(v.Company), + Notes: utils.FromPgText(v.Notes), + CreatedAt: utils.FromPgTimestamptz(v.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(v.UpdatedAt), + } +} + +type CreateContactRequest struct { + FirstName *string + LastName *string + Email *string + Phone *string + Company *string + Notes *string +} + +type UpdateContactRequest struct { + FirstName *string + LastName *string + Email *string + Phone *string + Company *string + Notes *string +} diff --git a/internal/service/event.go b/internal/service/event.go new file mode 100644 index 0000000..279512e --- /dev/null +++ b/internal/service/event.go @@ -0,0 +1,583 @@ +package service + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/teambition/rrule-go" +) + +type EventService struct { + pool *pgxpool.Pool + queries *repository.Queries + calendar *CalendarService + audit *AuditService + scheduler ReminderScheduler +} + +type ReminderScheduler interface { + ScheduleReminder(ctx context.Context, eventID, reminderID, userID uuid.UUID, triggerAt time.Time) error +} + +func NewEventService(pool *pgxpool.Pool, queries *repository.Queries, calendar *CalendarService, audit *AuditService, scheduler ReminderScheduler) *EventService { + return &EventService{pool: pool, queries: queries, calendar: calendar, audit: audit, scheduler: scheduler} +} + +func (s *EventService) Create(ctx context.Context, userID uuid.UUID, req CreateEventRequest) (*models.Event, error) { + if err := utils.ValidateEventTitle(req.Title); err != nil { + return nil, err + } + if req.Timezone == "" { + return nil, models.NewValidationError("timezone is required") + } + if err := utils.ValidateTimezone(req.Timezone); err != nil { + return nil, err + } + if err := utils.ValidateTimeRange(req.StartTime, req.EndTime); err != nil { + return nil, err + } + + role, err := s.calendar.GetRole(ctx, req.CalendarID, userID) + if err != nil { + return nil, err + } + if role != "owner" && role != "editor" { + return nil, models.ErrForbidden + } + + startUTC := req.StartTime.UTC() + endUTC := req.EndTime.UTC() + + if req.RecurrenceRule != nil && *req.RecurrenceRule != "" { + if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil { + return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error()) + } + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, models.ErrInternal + } + defer tx.Rollback(ctx) + qtx := s.queries.WithTx(tx) + + eventID := uuid.New() + tags := req.Tags + if tags == nil { + tags = []string{} + } + + dbEvent, err := qtx.CreateEvent(ctx, repository.CreateEventParams{ + ID: utils.ToPgUUID(eventID), + CalendarID: utils.ToPgUUID(req.CalendarID), + Title: req.Title, + Description: utils.ToPgTextPtr(req.Description), + Location: utils.ToPgTextPtr(req.Location), + StartTime: utils.ToPgTimestamptz(startUTC), + EndTime: utils.ToPgTimestamptz(endUTC), + Timezone: req.Timezone, + AllDay: req.AllDay, + RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule), + Tags: tags, + CreatedBy: utils.ToPgUUID(userID), + UpdatedBy: utils.ToPgUUID(userID), + }) + if err != nil { + return nil, models.ErrInternal + } + + var reminders []models.Reminder + for _, mins := range req.Reminders { + if err := utils.ValidateReminderMinutes(mins); err != nil { + return nil, err + } + rID := uuid.New() + r, err := qtx.CreateReminder(ctx, repository.CreateReminderParams{ + ID: utils.ToPgUUID(rID), + EventID: utils.ToPgUUID(eventID), + MinutesBefore: mins, + }) + if err != nil { + return nil, models.ErrInternal + } + reminders = append(reminders, models.Reminder{ + ID: utils.FromPgUUID(r.ID), + MinutesBefore: r.MinutesBefore, + }) + } + + if err := tx.Commit(ctx); err != nil { + return nil, models.ErrInternal + } + + for _, rem := range reminders { + triggerAt := startUTC.Add(-time.Duration(rem.MinutesBefore) * time.Minute) + if triggerAt.After(time.Now()) && s.scheduler != nil { + _ = s.scheduler.ScheduleReminder(ctx, eventID, rem.ID, userID, triggerAt) + } + } + + s.audit.Log(ctx, "event", eventID, "CREATE_EVENT", userID) + + if reminders == nil { + reminders = []models.Reminder{} + } + return eventFromDB(dbEvent, reminders, []models.Attendee{}, []models.Attachment{}), nil +} + +func (s *EventService) Get(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) (*models.Event, error) { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + if _, err := s.calendar.GetRole(ctx, calID, userID); err != nil { + return nil, err + } + + reminders, err := s.loadReminders(ctx, eventID) + if err != nil { + return nil, err + } + attendees, err := s.loadAttendees(ctx, eventID) + if err != nil { + return nil, err + } + attachments, err := s.loadAttachments(ctx, eventID) + if err != nil { + return nil, err + } + + return eventFromDB(ev, reminders, attendees, attachments), nil +} + +func (s *EventService) List(ctx context.Context, userID uuid.UUID, params ListEventParams) ([]models.Event, *string, error) { + if err := utils.ValidateRecurrenceRangeLimit(params.RangeStart, params.RangeEnd); err != nil { + return nil, nil, err + } + + limit := utils.ClampLimit(params.Limit) + + var cursorTime pgtype.Timestamptz + var cursorID pgtype.UUID + if params.Cursor != "" { + ct, cid, err := utils.ParseCursor(params.Cursor) + if err != nil { + return nil, nil, models.NewValidationError("invalid cursor") + } + cursorTime = utils.ToPgTimestamptzPtr(ct) + cursorID = utils.ToPgUUID(*cid) + } + + nonRecurring, err := s.queries.ListEventsInRange(ctx, repository.ListEventsInRangeParams{ + UserID: utils.ToPgUUID(userID), + RangeEnd: utils.ToPgTimestamptz(params.RangeEnd), + RangeStart: utils.ToPgTimestamptz(params.RangeStart), + CalendarID: optionalPgUUID(params.CalendarID), + Search: utils.ToPgTextPtr(params.Search), + Tag: utils.ToPgTextPtr(params.Tag), + CursorTime: cursorTime, + CursorID: cursorID, + Lim: limit + 1, + }) + if err != nil { + return nil, nil, models.ErrInternal + } + + recurring, err := s.queries.ListRecurringEventsInRange(ctx, repository.ListRecurringEventsInRangeParams{ + UserID: utils.ToPgUUID(userID), + RangeEnd: utils.ToPgTimestamptz(params.RangeEnd), + CalendarID: optionalPgUUID(params.CalendarID), + }) + if err != nil { + return nil, nil, models.ErrInternal + } + + var allEvents []models.Event + recurringIDs := make(map[uuid.UUID]bool) + + for _, ev := range recurring { + recurringIDs[utils.FromPgUUID(ev.ID)] = true + occurrences := s.expandRecurrence(ev, params.RangeStart, params.RangeEnd) + allEvents = append(allEvents, occurrences...) + } + + for _, ev := range nonRecurring { + eid := utils.FromPgUUID(ev.ID) + if recurringIDs[eid] { + continue + } + allEvents = append(allEvents, *eventFromDB(ev, nil, nil, nil)) + } + + sort.Slice(allEvents, func(i, j int) bool { + si := effectiveStart(allEvents[i]) + sj := effectiveStart(allEvents[j]) + if si.Equal(sj) { + return allEvents[i].ID.String() < allEvents[j].ID.String() + } + return si.Before(sj) + }) + + hasMore := int32(len(allEvents)) > limit + if hasMore { + allEvents = allEvents[:limit] + } + + relatedLoaded := make(map[uuid.UUID]bool) + remindersMap := make(map[uuid.UUID][]models.Reminder) + attendeesMap := make(map[uuid.UUID][]models.Attendee) + attachmentsMap := make(map[uuid.UUID][]models.Attachment) + for _, ev := range allEvents { + if relatedLoaded[ev.ID] { + continue + } + relatedLoaded[ev.ID] = true + remindersMap[ev.ID], _ = s.loadReminders(ctx, ev.ID) + attendeesMap[ev.ID], _ = s.loadAttendees(ctx, ev.ID) + attachmentsMap[ev.ID], _ = s.loadAttachments(ctx, ev.ID) + } + for i := range allEvents { + allEvents[i].Reminders = remindersMap[allEvents[i].ID] + allEvents[i].Attendees = attendeesMap[allEvents[i].ID] + allEvents[i].Attachments = attachmentsMap[allEvents[i].ID] + } + + if hasMore { + last := allEvents[len(allEvents)-1] + cursor := utils.EncodeCursor(effectiveStart(last), last.ID) + return allEvents, &cursor, nil + } + + return allEvents, nil, nil +} + +func (s *EventService) Update(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, req UpdateEventRequest) (*models.Event, error) { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return nil, err + } + if role != "owner" && role != "editor" { + return nil, models.ErrForbidden + } + + if req.Title != nil { + if err := utils.ValidateEventTitle(*req.Title); err != nil { + return nil, err + } + } + if req.Timezone != nil { + if err := utils.ValidateTimezone(*req.Timezone); err != nil { + return nil, err + } + } + + startTime := utils.FromPgTimestamptz(ev.StartTime) + endTime := utils.FromPgTimestamptz(ev.EndTime) + if req.StartTime != nil { + startTime = req.StartTime.UTC() + } + if req.EndTime != nil { + endTime = req.EndTime.UTC() + } + if err := utils.ValidateTimeRange(startTime, endTime); err != nil { + return nil, err + } + + if req.RecurrenceRule != nil && *req.RecurrenceRule != "" { + if _, err := rrule.StrToRRule(*req.RecurrenceRule); err != nil { + return nil, models.NewValidationError("invalid recurrence_rule: " + err.Error()) + } + } + + var pgStart, pgEnd pgtype.Timestamptz + if req.StartTime != nil { + pgStart = utils.ToPgTimestamptz(startTime) + } + if req.EndTime != nil { + pgEnd = utils.ToPgTimestamptz(endTime) + } + + updated, err := s.queries.UpdateEvent(ctx, repository.UpdateEventParams{ + ID: utils.ToPgUUID(eventID), + Title: utils.ToPgTextPtr(req.Title), + Description: utils.ToPgTextPtr(req.Description), + Location: utils.ToPgTextPtr(req.Location), + StartTime: pgStart, + EndTime: pgEnd, + Timezone: utils.ToPgTextPtr(req.Timezone), + AllDay: optionalPgBool(req.AllDay), + RecurrenceRule: utils.ToPgTextPtr(req.RecurrenceRule), + Tags: req.Tags, + UpdatedBy: utils.ToPgUUID(userID), + }) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + s.audit.Log(ctx, "event", eventID, "UPDATE_EVENT", userID) + + reminders, _ := s.loadReminders(ctx, eventID) + attendees, _ := s.loadAttendees(ctx, eventID) + attachments, _ := s.loadAttachments(ctx, eventID) + return eventFromDB(updated, reminders, attendees, attachments), nil +} + +func (s *EventService) Delete(ctx context.Context, userID uuid.UUID, eventID uuid.UUID) error { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return models.ErrNotFound + } + return models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return err + } + if role != "owner" && role != "editor" { + return models.ErrForbidden + } + + if err := s.queries.SoftDeleteEvent(ctx, utils.ToPgUUID(eventID)); err != nil { + return models.ErrInternal + } + + s.audit.Log(ctx, "event", eventID, "DELETE_EVENT", userID) + return nil +} + +func (s *EventService) expandRecurrence(ev repository.Event, rangeStart, rangeEnd time.Time) []models.Event { + if !ev.RecurrenceRule.Valid { + return nil + } + + ruleStr := ev.RecurrenceRule.String + dtStart := utils.FromPgTimestamptz(ev.StartTime) + duration := utils.FromPgTimestamptz(ev.EndTime).Sub(dtStart) + + fullRule := fmt.Sprintf("DTSTART:%s\nRRULE:%s", dtStart.UTC().Format("20060102T150405Z"), ruleStr) + rule, err := rrule.StrToRRuleSet(fullRule) + if err != nil { + return nil + } + + exceptions, _ := s.queries.ListExceptionsByEvent(context.Background(), ev.ID) + exceptionDates := make(map[string]bool) + for _, ex := range exceptions { + if ex.ExceptionDate.Valid { + exceptionDates[ex.ExceptionDate.Time.Format("2006-01-02")] = true + } + } + + occurrences := rule.Between(rangeStart.UTC(), rangeEnd.UTC(), true) + var results []models.Event + + for _, occ := range occurrences { + dateKey := occ.Format("2006-01-02") + if exceptionDates[dateKey] { + continue + } + + occEnd := occ.Add(duration) + occStart := occ + results = append(results, models.Event{ + ID: utils.FromPgUUID(ev.ID), + CalendarID: utils.FromPgUUID(ev.CalendarID), + Title: ev.Title, + Description: utils.FromPgText(ev.Description), + Location: utils.FromPgText(ev.Location), + StartTime: dtStart, + EndTime: dtStart.Add(duration), + Timezone: ev.Timezone, + AllDay: ev.AllDay, + RecurrenceRule: utils.FromPgText(ev.RecurrenceRule), + IsOccurrence: true, + OccurrenceStartTime: &occStart, + OccurrenceEndTime: &occEnd, + CreatedBy: utils.FromPgUUID(ev.CreatedBy), + UpdatedBy: utils.FromPgUUID(ev.UpdatedBy), + CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt), + Tags: ev.Tags, + Reminders: []models.Reminder{}, + Attendees: []models.Attendee{}, + Attachments: []models.Attachment{}, + }) + } + + return results +} + +func (s *EventService) loadReminders(ctx context.Context, eventID uuid.UUID) ([]models.Reminder, error) { + rows, err := s.queries.ListRemindersByEvent(ctx, utils.ToPgUUID(eventID)) + if err != nil { + return nil, models.ErrInternal + } + result := make([]models.Reminder, len(rows)) + for i, r := range rows { + result[i] = models.Reminder{ + ID: utils.FromPgUUID(r.ID), + MinutesBefore: r.MinutesBefore, + } + } + return result, nil +} + +func (s *EventService) loadAttendees(ctx context.Context, eventID uuid.UUID) ([]models.Attendee, error) { + rows, err := s.queries.ListAttendeesByEvent(ctx, utils.ToPgUUID(eventID)) + if err != nil { + return nil, models.ErrInternal + } + result := make([]models.Attendee, len(rows)) + for i, a := range rows { + var uid *uuid.UUID + if a.UserID.Valid { + u := utils.FromPgUUID(a.UserID) + uid = &u + } + result[i] = models.Attendee{ + ID: utils.FromPgUUID(a.ID), + UserID: uid, + Email: utils.FromPgText(a.Email), + Status: a.Status, + } + } + return result, nil +} + +func (s *EventService) loadAttachments(ctx context.Context, eventID uuid.UUID) ([]models.Attachment, error) { + rows, err := s.queries.ListAttachmentsByEvent(ctx, utils.ToPgUUID(eventID)) + if err != nil { + return nil, models.ErrInternal + } + result := make([]models.Attachment, len(rows)) + for i, a := range rows { + result[i] = models.Attachment{ + ID: utils.FromPgUUID(a.ID), + FileURL: a.FileUrl, + } + } + return result, nil +} + +func eventFromDB(ev repository.Event, reminders []models.Reminder, attendees []models.Attendee, attachments []models.Attachment) *models.Event { + if reminders == nil { + reminders = []models.Reminder{} + } + if attendees == nil { + attendees = []models.Attendee{} + } + if attachments == nil { + attachments = []models.Attachment{} + } + tags := ev.Tags + if tags == nil { + tags = []string{} + } + + return &models.Event{ + ID: utils.FromPgUUID(ev.ID), + CalendarID: utils.FromPgUUID(ev.CalendarID), + Title: ev.Title, + Description: utils.FromPgText(ev.Description), + Location: utils.FromPgText(ev.Location), + StartTime: utils.FromPgTimestamptz(ev.StartTime), + EndTime: utils.FromPgTimestamptz(ev.EndTime), + Timezone: ev.Timezone, + AllDay: ev.AllDay, + RecurrenceRule: utils.FromPgText(ev.RecurrenceRule), + CreatedBy: utils.FromPgUUID(ev.CreatedBy), + UpdatedBy: utils.FromPgUUID(ev.UpdatedBy), + CreatedAt: utils.FromPgTimestamptz(ev.CreatedAt), + UpdatedAt: utils.FromPgTimestamptz(ev.UpdatedAt), + Reminders: reminders, + Attendees: attendees, + Attachments: attachments, + Tags: tags, + } +} + +type CreateEventRequest struct { + CalendarID uuid.UUID + Title string + Description *string + Location *string + StartTime time.Time + EndTime time.Time + Timezone string + AllDay bool + RecurrenceRule *string + Reminders []int32 + Tags []string +} + +type UpdateEventRequest struct { + Title *string + Description *string + Location *string + StartTime *time.Time + EndTime *time.Time + Timezone *string + AllDay *bool + RecurrenceRule *string + Tags []string +} + +type ListEventParams struct { + RangeStart time.Time + RangeEnd time.Time + CalendarID *uuid.UUID + Search *string + Tag *string + Limit int + Cursor string +} + +func effectiveStart(e models.Event) time.Time { + if e.OccurrenceStartTime != nil { + return *e.OccurrenceStartTime + } + return e.StartTime +} + +func optionalPgUUID(id *uuid.UUID) pgtype.UUID { + if id == nil { + return pgtype.UUID{Valid: false} + } + return utils.ToPgUUID(*id) +} + +func optionalPgBool(b *bool) pgtype.Bool { + if b == nil { + return pgtype.Bool{Valid: false} + } + return pgtype.Bool{Bool: *b, Valid: true} +} diff --git a/internal/service/reminder.go b/internal/service/reminder.go new file mode 100644 index 0000000..e65bfdd --- /dev/null +++ b/internal/service/reminder.go @@ -0,0 +1,132 @@ +package service + +import ( + "context" + "time" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +type ReminderService struct { + queries *repository.Queries + calendar *CalendarService + scheduler ReminderScheduler +} + +func NewReminderService(queries *repository.Queries, calendar *CalendarService, scheduler ReminderScheduler) *ReminderService { + return &ReminderService{queries: queries, calendar: calendar, scheduler: scheduler} +} + +func (s *ReminderService) AddReminders(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, minutesBefore []int32) (*models.Event, error) { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return nil, err + } + if role != "owner" && role != "editor" { + return nil, models.ErrForbidden + } + + for _, mins := range minutesBefore { + if err := utils.ValidateReminderMinutes(mins); err != nil { + return nil, err + } + rID := uuid.New() + _, err := s.queries.CreateReminder(ctx, repository.CreateReminderParams{ + ID: utils.ToPgUUID(rID), + EventID: utils.ToPgUUID(eventID), + MinutesBefore: mins, + }) + if err != nil { + return nil, models.ErrInternal + } + + startTime := utils.FromPgTimestamptz(ev.StartTime) + triggerAt := startTime.Add(-time.Duration(mins) * time.Minute) + if triggerAt.After(time.Now()) && s.scheduler != nil { + _ = s.scheduler.ScheduleReminder(ctx, eventID, rID, userID, triggerAt) + } + } + + reminders, _ := loadRemindersHelper(ctx, s.queries, eventID) + attendees, _ := loadAttendeesHelper(ctx, s.queries, eventID) + attachments, _ := loadAttachmentsHelper(ctx, s.queries, eventID) + return eventFromDB(ev, reminders, attendees, attachments), nil +} + +func (s *ReminderService) DeleteReminder(ctx context.Context, userID uuid.UUID, eventID uuid.UUID, reminderID uuid.UUID) error { + ev, err := s.queries.GetEventByID(ctx, utils.ToPgUUID(eventID)) + if err != nil { + if err == pgx.ErrNoRows { + return models.ErrNotFound + } + return models.ErrInternal + } + + calID := utils.FromPgUUID(ev.CalendarID) + role, err := s.calendar.GetRole(ctx, calID, userID) + if err != nil { + return err + } + if role != "owner" && role != "editor" { + return models.ErrForbidden + } + + return s.queries.DeleteReminder(ctx, repository.DeleteReminderParams{ + ID: utils.ToPgUUID(reminderID), + EventID: utils.ToPgUUID(eventID), + }) +} + +func loadRemindersHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Reminder, error) { + rows, err := q.ListRemindersByEvent(ctx, utils.ToPgUUID(eventID)) + if err != nil { + return []models.Reminder{}, err + } + result := make([]models.Reminder, len(rows)) + for i, r := range rows { + result[i] = models.Reminder{ID: utils.FromPgUUID(r.ID), MinutesBefore: r.MinutesBefore} + } + return result, nil +} + +func loadAttendeesHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Attendee, error) { + rows, err := q.ListAttendeesByEvent(ctx, utils.ToPgUUID(eventID)) + if err != nil { + return []models.Attendee{}, err + } + result := make([]models.Attendee, len(rows)) + for i, a := range rows { + var uid *uuid.UUID + if a.UserID.Valid { + u := utils.FromPgUUID(a.UserID) + uid = &u + } + result[i] = models.Attendee{ID: utils.FromPgUUID(a.ID), UserID: uid, Email: utils.FromPgText(a.Email), Status: a.Status} + } + return result, nil +} + +func loadAttachmentsHelper(ctx context.Context, q *repository.Queries, eventID uuid.UUID) ([]models.Attachment, error) { + rows, err := q.ListAttachmentsByEvent(ctx, utils.ToPgUUID(eventID)) + if err != nil { + return []models.Attachment{}, err + } + result := make([]models.Attachment, len(rows)) + for i, a := range rows { + result[i] = models.Attachment{ID: utils.FromPgUUID(a.ID), FileURL: a.FileUrl} + } + return result, nil +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..b8526f1 --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + + "github.com/calendarapi/internal/models" + "github.com/calendarapi/internal/repository" + "github.com/calendarapi/internal/utils" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type UserService struct { + pool *pgxpool.Pool + queries *repository.Queries + audit *AuditService +} + +func NewUserService(pool *pgxpool.Pool, queries *repository.Queries, audit *AuditService) *UserService { + return &UserService{pool: pool, queries: queries, audit: audit} +} + +func (s *UserService) GetMe(ctx context.Context, userID uuid.UUID) (*models.User, error) { + u, err := s.queries.GetUserByID(ctx, utils.ToPgUUID(userID)) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + user := userFromIDRow(u) + return &user, nil +} + +func (s *UserService) Update(ctx context.Context, userID uuid.UUID, timezone *string) (*models.User, error) { + if timezone != nil { + if err := utils.ValidateTimezone(*timezone); err != nil { + return nil, err + } + } + + u, err := s.queries.UpdateUser(ctx, repository.UpdateUserParams{ + ID: utils.ToPgUUID(userID), + Timezone: utils.ToPgTextPtr(timezone), + }) + if err != nil { + if err == pgx.ErrNoRows { + return nil, models.ErrNotFound + } + return nil, models.ErrInternal + } + user := userFromUpdateRow(u) + return &user, nil +} + +func (s *UserService) Delete(ctx context.Context, userID uuid.UUID) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return models.ErrInternal + } + defer tx.Rollback(ctx) + + qtx := s.queries.WithTx(tx) + pgID := utils.ToPgUUID(userID) + + if err := qtx.SoftDeleteContactsByOwner(ctx, pgID); err != nil { + return models.ErrInternal + } + if err := qtx.SoftDeleteEventsByCreator(ctx, pgID); err != nil { + return models.ErrInternal + } + if err := qtx.SoftDeleteCalendarsByOwner(ctx, pgID); err != nil { + return models.ErrInternal + } + if err := qtx.RevokeAllUserAPIKeys(ctx, pgID); err != nil { + return models.ErrInternal + } + if err := qtx.RevokeAllUserRefreshTokens(ctx, pgID); err != nil { + return models.ErrInternal + } + if err := qtx.SoftDeleteUser(ctx, pgID); err != nil { + return models.ErrInternal + } + + if err := tx.Commit(ctx); err != nil { + return models.ErrInternal + } + + s.audit.Log(ctx, "user", userID, "DELETE_USER", userID) + return nil +} diff --git a/internal/utils/pagination.go b/internal/utils/pagination.go new file mode 100644 index 0000000..1a08a97 --- /dev/null +++ b/internal/utils/pagination.go @@ -0,0 +1,59 @@ +package utils + +import ( + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + DefaultLimit = 50 + MaxLimit = 200 +) + +type CursorParams struct { + CursorTime *time.Time + CursorID *uuid.UUID + Limit int32 +} + +func ParseCursor(cursor string) (*time.Time, *uuid.UUID, error) { + if cursor == "" { + return nil, nil, nil + } + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return nil, nil, fmt.Errorf("invalid cursor encoding") + } + parts := strings.SplitN(string(raw), "|", 2) + if len(parts) != 2 { + return nil, nil, fmt.Errorf("invalid cursor format") + } + t, err := time.Parse(time.RFC3339Nano, parts[0]) + if err != nil { + return nil, nil, fmt.Errorf("invalid cursor time") + } + id, err := uuid.Parse(parts[1]) + if err != nil { + return nil, nil, fmt.Errorf("invalid cursor id") + } + return &t, &id, nil +} + +func EncodeCursor(t time.Time, id uuid.UUID) string { + raw := fmt.Sprintf("%s|%s", t.Format(time.RFC3339Nano), id.String()) + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +func ClampLimit(limit int) int32 { + if limit <= 0 { + return DefaultLimit + } + if limit > MaxLimit { + return MaxLimit + } + return int32(limit) +} diff --git a/internal/utils/pgtype.go b/internal/utils/pgtype.go new file mode 100644 index 0000000..3cfe5a9 --- /dev/null +++ b/internal/utils/pgtype.go @@ -0,0 +1,88 @@ +package utils + +import ( + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func ToPgUUID(id uuid.UUID) pgtype.UUID { + return pgtype.UUID{Bytes: id, Valid: true} +} + +func FromPgUUID(id pgtype.UUID) uuid.UUID { + if !id.Valid { + return uuid.Nil + } + return uuid.UUID(id.Bytes) +} + +func ToPgTimestamptz(t time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: t, Valid: true} +} + +func ToPgTimestamptzPtr(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +func FromPgTimestamptz(t pgtype.Timestamptz) time.Time { + if !t.Valid { + return time.Time{} + } + return t.Time +} + +func FromPgTimestamptzPtr(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + return &t.Time +} + +func ToPgText(s string) pgtype.Text { + if s == "" { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: s, Valid: true} +} + +func ToPgTextPtr(s *string) pgtype.Text { + if s == nil { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: *s, Valid: true} +} + +func FromPgText(t pgtype.Text) *string { + if !t.Valid { + return nil + } + return &t.String +} + +func FromPgTextValue(t pgtype.Text) string { + if !t.Valid { + return "" + } + return t.String +} + +func ToPgBool(b bool) pgtype.Bool { + return pgtype.Bool{Bool: b, Valid: true} +} + +func NullPgUUID() pgtype.UUID { + return pgtype.UUID{Valid: false} +} + +func NullPgTimestamptz() pgtype.Timestamptz { + return pgtype.Timestamptz{Valid: false} +} + +func NullPgText() pgtype.Text { + return pgtype.Text{Valid: false} +} diff --git a/internal/utils/response.go b/internal/utils/response.go new file mode 100644 index 0000000..34c8c3f --- /dev/null +++ b/internal/utils/response.go @@ -0,0 +1,44 @@ +package utils + +import ( + "encoding/json" + "net/http" + + "github.com/calendarapi/internal/models" +) + +func WriteJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func WriteError(w http.ResponseWriter, err error) { + if appErr, ok := models.IsAppError(err); ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(appErr.Status) + json.NewEncoder(w).Encode(appErr) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(models.ErrInternal) +} + +func WriteOK(w http.ResponseWriter) { + WriteJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func WriteList(w http.ResponseWriter, items interface{}, page models.PageInfo) { + WriteJSON(w, http.StatusOK, models.ListResponse{Items: items, Page: page}) +} + +func DecodeJSON(r *http.Request, dst interface{}) error { + if r.Body == nil { + return models.NewValidationError("request body required") + } + if err := json.NewDecoder(r.Body).Decode(dst); err != nil { + return models.NewValidationError("invalid JSON: " + err.Error()) + } + return nil +} diff --git a/internal/utils/validation.go b/internal/utils/validation.go new file mode 100644 index 0000000..6979275 --- /dev/null +++ b/internal/utils/validation.go @@ -0,0 +1,97 @@ +package utils + +import ( + "net/mail" + "regexp" + "strings" + "time" + + "github.com/calendarapi/internal/models" + "github.com/google/uuid" +) + +var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +func ValidateEmail(email string) error { + if email == "" { + return models.NewValidationError("email is required") + } + if _, err := mail.ParseAddress(email); err != nil { + return models.NewValidationError("invalid email format") + } + return nil +} + +func ValidatePassword(password string) error { + if len(password) < 10 { + return models.NewValidationError("password must be at least 10 characters") + } + return nil +} + +func ValidateTimezone(tz string) error { + if tz == "" { + return nil + } + if _, err := time.LoadLocation(tz); err != nil { + return models.NewValidationError("invalid IANA timezone: " + tz) + } + return nil +} + +func ValidateCalendarName(name string) error { + if len(name) < 1 || len(name) > 80 { + return models.NewValidationError("calendar name must be 1-80 characters") + } + return nil +} + +func ValidateColor(color string) error { + if color == "" { + return nil + } + if !hexColorRegex.MatchString(color) { + return models.NewValidationError("color must be hex format #RRGGBB") + } + return nil +} + +func ValidateEventTitle(title string) error { + if len(title) < 1 || len(title) > 140 { + return models.NewValidationError("event title must be 1-140 characters") + } + return nil +} + +func ValidateTimeRange(start, end time.Time) error { + if !end.After(start) { + return models.NewValidationError("end_time must be after start_time") + } + return nil +} + +func ValidateReminderMinutes(minutes int32) error { + if minutes < 0 || minutes > 10080 { + return models.NewValidationError("minutes_before must be 0-10080") + } + return nil +} + +func ValidateUUID(s string) (uuid.UUID, error) { + id, err := uuid.Parse(s) + if err != nil { + return uuid.Nil, models.NewValidationError("invalid UUID: " + s) + } + return id, nil +} + +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func ValidateRecurrenceRangeLimit(start, end time.Time) error { + if end.Sub(start) > 366*24*time.Hour { + return models.NewValidationError("date range cannot exceed 1 year") + } + return nil +} diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..e7e793a --- /dev/null +++ b/llms.txt @@ -0,0 +1,129 @@ +# Calendar & Contacts API + +> Production-grade REST API for calendar management, event scheduling, contacts, availability, and public booking. Built for humans, AI agents, and programmatic automation. + +## Base URL + +- Local: http://localhost:3019 +- OpenAPI spec: GET /openapi.json +- Swagger UI: GET /docs + +## Authentication + +Two methods, both sent as HTTP headers: + +- JWT: `Authorization: Bearer ` - short-lived (15 min), obtained via login/register +- API Key: `X-API-Key: ` - long-lived, scoped, created via POST /api-keys (recommended for agents) + +If both headers are present, JWT takes precedence. + +## Auth Endpoints + +POST /auth/register - Create account. Body: {"email", "password" (>=10 chars), "timezone"?}. Returns {user, access_token, refresh_token}. No auth required. +POST /auth/login - Login. Body: {"email", "password"}. Returns {user, access_token, refresh_token}. No auth required. +POST /auth/refresh - Refresh tokens. Body: {"refresh_token"}. Returns {access_token, refresh_token}. No auth required. +POST /auth/logout - Revoke refresh token. Body: {"refresh_token"}. Returns {"ok": true}. Requires auth. +GET /auth/me - Get current user. Returns {"user": {...}}. Requires auth. + +## User Endpoints + +GET /users/me - Get profile. Returns {"user": {id, email, timezone, created_at, updated_at}}. +PUT /users/me - Update profile. Body: {"timezone"?}. Returns {"user": {...}}. +DELETE /users/me - Soft-delete account and all associated data. Returns {"ok": true}. + +## API Key Endpoints + +POST /api-keys - Create API key. Body: {"name", "scopes": {"calendars": ["read","write"], "events": ["read","write"], "contacts": ["read","write"], "availability": ["read"], "booking": ["write"]}}. Returns {id, name, created_at, token}. Token shown once. +GET /api-keys - List keys. Returns {"items": [...], "page": {limit, next_cursor}}. +DELETE /api-keys/{id} - Revoke key. Returns {"ok": true}. + +## Calendar Endpoints (scope: calendars:read/write) + +GET /calendars - List all calendars (owned + shared). Returns {"items": [Calendar], "page": {...}}. +POST /calendars - Create. Body: {"name" (1-80), "color"? (#RRGGBB)}. Returns {"calendar": Calendar}. +GET /calendars/{id} - Get by ID. Returns {"calendar": Calendar}. +PUT /calendars/{id} - Update. Body: {"name"?, "color"?, "is_public"? (owner only)}. Returns {"calendar": Calendar}. +DELETE /calendars/{id} - Soft-delete (owner only). Returns {"ok": true}. + +### Calendar Sharing + +POST /calendars/{id}/share - Share. Body: {"target": {"email": "..."}, "role": "editor"|"viewer"}. Owner only. +GET /calendars/{id}/members - List members. Returns {"items": [CalendarMember], "page": {...}}. +DELETE /calendars/{id}/members/{userID} - Remove member. Owner only. Cannot remove owner. + +## Event Endpoints (scope: events:read/write) + +GET /events?start=RFC3339&end=RFC3339 - List events in range. Optional: calendar_id, search, tag, limit (1-200), cursor. Recurring events expanded into occurrences. Returns {"items": [Event], "page": {...}}. +POST /events - Create. Body: {"calendar_id", "title" (1-140), "start_time", "end_time", "timezone" (IANA), "all_day"?, "description"?, "location"?, "recurrence_rule"? (RFC5545 RRULE), "reminders"? ([minutes]), "tags"? ([string])}. Returns {"event": Event}. +GET /events/{id} - Get with reminders, attendees, tags, attachments. Returns {"event": Event}. +PUT /events/{id} - Update. Body: any event fields. Returns {"event": Event}. +DELETE /events/{id} - Soft-delete. Returns {"ok": true}. + +### Event Reminders + +POST /events/{id}/reminders - Add reminders. Body: {"minutes_before": [5, 15, 60]} (0-10080). Returns {"event": Event}. +DELETE /events/{id}/reminders/{reminderID} - Remove. Returns {"ok": true}. + +### Event Attendees + +POST /events/{id}/attendees - Add. Body: {"attendees": [{"email": "..."} or {"user_id": "..."}]}. Returns {"event": Event}. +PUT /events/{id}/attendees/{attendeeID} - Update RSVP. Body: {"status": "accepted"|"declined"|"tentative"}. Returns {"event": Event}. +DELETE /events/{id}/attendees/{attendeeID} - Remove. Returns {"ok": true}. + +## Contact Endpoints (scope: contacts:read/write) + +GET /contacts - List. Optional: search, limit (1-200), cursor. Returns {"items": [Contact], "page": {...}}. +POST /contacts - Create. Body: {"first_name"?, "last_name"?, "email"?, "phone"?, "company"?, "notes"?}. At least one of first_name/last_name/email/phone required. Returns {"contact": Contact}. +GET /contacts/{id} - Get. Returns {"contact": Contact}. +PUT /contacts/{id} - Update. Returns {"contact": Contact}. +DELETE /contacts/{id} - Soft-delete. Returns {"ok": true}. + +## Availability Endpoint (scope: availability:read) + +GET /availability?calendar_id=UUID&start=RFC3339&end=RFC3339 - Returns busy blocks including expanded recurring events. Response: {"calendar_id", "range_start", "range_end", "busy": [{"start", "end", "event_id"}]}. + +## Booking Endpoints + +POST /calendars/{id}/booking-link - Create booking link (scope: booking:write, owner only). Body: {"duration_minutes", "buffer_minutes"?, "timezone", "working_hours": {"mon": [{"start": "HH:MM", "end": "HH:MM"}], ...}, "active"?}. Returns {"token", "public_url", "settings": {...}}. +GET /booking/{token}/availability?start=RFC3339&end=RFC3339 - Public, no auth. Returns {"token", "timezone", "duration_minutes", "slots": [{"start", "end"}]}. +POST /booking/{token}/reserve - Public, no auth. Body: {"name", "email", "slot_start", "slot_end", "notes"?}. Returns {"ok": true, "event": Event}. 409 if slot taken. + +## ICS Import/Export + +GET /calendars/{id}/export.ics - Export as ICS (scope: calendars:read). Returns text/calendar. +POST /calendars/import - Import ICS (scope: calendars:write). Multipart form: calendar_id (uuid) + file (.ics). Returns {"ok": true, "imported": {"events": N}}. + +## Data Schemas + +User: {id, email, timezone, created_at, updated_at} +Calendar: {id, name, color, is_public, role, created_at, updated_at} +Event: {id, calendar_id, title, description?, location?, start_time, end_time, timezone, all_day, recurrence_rule?, is_occurrence, occurrence_start_time?, occurrence_end_time?, created_by, updated_by, created_at, updated_at, reminders[], attendees[], tags[], attachments[]} +Contact: {id, first_name?, last_name?, email?, phone?, company?, notes?, created_at, updated_at} +Reminder: {id, minutes_before} +Attendee: {id, user_id?, email?, status} +CalendarMember: {user_id, email, role} +BusyBlock: {start, end, event_id} +TimeSlot: {start, end} + +## Error Format + +All errors: {"error": "message", "code": "CODE", "details"?: any} +Codes: VALIDATION_ERROR (400), AUTH_REQUIRED (401), AUTH_INVALID (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), RATE_LIMITED (429), INTERNAL (500) + +## Pagination + +Cursor-based. Query params: limit (default 50, max 200), cursor (opaque string). Response: {"items": [...], "page": {"limit": N, "next_cursor": string or null}}. Null cursor means last page. + +## Key Constraints + +- Passwords: >= 10 characters +- Calendar names: 1-80 characters +- Event titles: 1-140 characters +- Colors: #RRGGBB hex +- Timezones: IANA names (e.g. America/New_York, UTC) +- Recurrence: RFC 5545 RRULE (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR) +- Reminders: 0-10080 minutes before +- Event list range: max 1 year +- Soft deletion throughout (data recoverable) +- Calendar roles: owner (full), editor (events CRUD), viewer (read-only) +- Only owners can share calendars, delete calendars, create booking links diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql new file mode 100644 index 0000000..6ac60a0 --- /dev/null +++ b/migrations/000001_init.down.sql @@ -0,0 +1,13 @@ +DROP TABLE IF EXISTS audit_logs; +DROP TABLE IF EXISTS booking_links; +DROP TABLE IF EXISTS event_attachments; +DROP TABLE IF EXISTS event_exceptions; +DROP TABLE IF EXISTS event_attendees; +DROP TABLE IF EXISTS event_reminders; +DROP TABLE IF EXISTS events; +DROP TABLE IF EXISTS calendar_members; +DROP TABLE IF EXISTS calendars; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS contacts; +DROP TABLE IF EXISTS users; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql new file mode 100644 index 0000000..088dc56 --- /dev/null +++ b/migrations/000001_init.up.sql @@ -0,0 +1,174 @@ +-- Calendar & Contacts API Schema + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL; + +-- Refresh Tokens +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id); + +-- API Keys +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_api_keys_user_id ON api_keys (user_id); + +-- Calendars +CREATE TABLE calendars ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#3B82F6', + is_public BOOLEAN NOT NULL DEFAULT false, + public_token TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_calendars_owner_id ON calendars (owner_id); + +-- Calendar Members +CREATE TABLE calendar_members ( + calendar_id UUID NOT NULL REFERENCES calendars(id), + user_id UUID NOT NULL REFERENCES users(id), + role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')), + PRIMARY KEY (calendar_id, user_id) +); + +-- Events +CREATE TABLE events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + calendar_id UUID NOT NULL REFERENCES calendars(id), + title TEXT NOT NULL, + description TEXT, + location TEXT, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + all_day BOOLEAN NOT NULL DEFAULT false, + recurrence_rule TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + created_by UUID NOT NULL REFERENCES users(id), + updated_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_events_calendar_start ON events (calendar_id, start_time); +CREATE INDEX idx_events_start_time ON events (start_time); +CREATE INDEX idx_events_tags ON events USING GIN (tags); + +-- Event Reminders +CREATE TABLE event_reminders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + minutes_before INTEGER NOT NULL CHECK (minutes_before >= 0 AND minutes_before <= 10080) +); + +CREATE INDEX idx_event_reminders_event_id ON event_reminders (event_id); + +-- Event Attendees +CREATE TABLE event_attendees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + user_id UUID REFERENCES users(id), + email TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'tentative')) +); + +CREATE INDEX idx_event_attendees_event_id ON event_attendees (event_id); + +-- Event Exceptions (for recurrence) +CREATE TABLE event_exceptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + exception_date DATE NOT NULL, + action TEXT NOT NULL DEFAULT 'skip' CHECK (action IN ('skip')) +); + +CREATE INDEX idx_event_exceptions_event_id ON event_exceptions (event_id); + +-- Event Attachments +CREATE TABLE event_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + file_url TEXT NOT NULL +); + +CREATE INDEX idx_event_attachments_event_id ON event_attachments (event_id); + +-- Contacts +CREATE TABLE contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id), + first_name TEXT, + last_name TEXT, + email TEXT, + phone TEXT, + company TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_contacts_owner_id ON contacts (owner_id); +CREATE INDEX idx_contacts_search ON contacts (owner_id, first_name, last_name, email, company); + +-- Booking Links +CREATE TABLE booking_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + calendar_id UUID NOT NULL REFERENCES calendars(id), + token TEXT NOT NULL UNIQUE, + duration_minutes INTEGER NOT NULL, + buffer_minutes INTEGER NOT NULL DEFAULT 0, + timezone TEXT NOT NULL DEFAULT 'UTC', + working_hours JSONB NOT NULL DEFAULT '{}', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_booking_links_token ON booking_links (token); + +-- Audit Logs +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + action TEXT NOT NULL, + user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_logs_entity ON audit_logs (entity_type, entity_id); diff --git a/sqlc/queries/api_keys.sql b/sqlc/queries/api_keys.sql new file mode 100644 index 0000000..6d56fed --- /dev/null +++ b/sqlc/queries/api_keys.sql @@ -0,0 +1,23 @@ +-- name: CreateAPIKey :one +INSERT INTO api_keys (id, user_id, name, key_hash, scopes) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, user_id, name, key_hash, scopes, created_at, revoked_at; + +-- name: ListAPIKeysByUser :many +SELECT id, name, scopes, created_at, revoked_at +FROM api_keys +WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: GetAPIKeyByHash :one +SELECT id, user_id, name, key_hash, scopes, created_at, revoked_at +FROM api_keys +WHERE key_hash = $1 AND revoked_at IS NULL; + +-- name: RevokeAPIKey :exec +UPDATE api_keys SET revoked_at = now() +WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL; + +-- name: RevokeAllUserAPIKeys :exec +UPDATE api_keys SET revoked_at = now() +WHERE user_id = $1 AND revoked_at IS NULL; diff --git a/sqlc/queries/attachments.sql b/sqlc/queries/attachments.sql new file mode 100644 index 0000000..b35d253 --- /dev/null +++ b/sqlc/queries/attachments.sql @@ -0,0 +1,13 @@ +-- name: ListAttachmentsByEvent :many +SELECT id, event_id, file_url +FROM event_attachments +WHERE event_id = $1 +ORDER BY id ASC; + +-- name: CreateAttachment :one +INSERT INTO event_attachments (id, event_id, file_url) +VALUES ($1, $2, $3) +RETURNING id, event_id, file_url; + +-- name: DeleteAttachment :exec +DELETE FROM event_attachments WHERE id = $1 AND event_id = $2; diff --git a/sqlc/queries/attendees.sql b/sqlc/queries/attendees.sql new file mode 100644 index 0000000..567e10f --- /dev/null +++ b/sqlc/queries/attendees.sql @@ -0,0 +1,25 @@ +-- name: CreateAttendee :one +INSERT INTO event_attendees (id, event_id, user_id, email, status) +VALUES ($1, $2, $3, $4, 'pending') +RETURNING id, event_id, user_id, email, status; + +-- name: ListAttendeesByEvent :many +SELECT id, event_id, user_id, email, status +FROM event_attendees +WHERE event_id = $1 +ORDER BY id ASC; + +-- name: UpdateAttendeeStatus :one +UPDATE event_attendees +SET status = $2 +WHERE id = $1 +RETURNING id, event_id, user_id, email, status; + +-- name: DeleteAttendee :exec +DELETE FROM event_attendees +WHERE id = $1 AND event_id = $2; + +-- name: GetAttendeeByID :one +SELECT id, event_id, user_id, email, status +FROM event_attendees +WHERE id = $1; diff --git a/sqlc/queries/audit_logs.sql b/sqlc/queries/audit_logs.sql new file mode 100644 index 0000000..f45a65b --- /dev/null +++ b/sqlc/queries/audit_logs.sql @@ -0,0 +1,3 @@ +-- name: CreateAuditLog :exec +INSERT INTO audit_logs (entity_type, entity_id, action, user_id) +VALUES ($1, $2, $3, $4); diff --git a/sqlc/queries/booking_links.sql b/sqlc/queries/booking_links.sql new file mode 100644 index 0000000..10e32bc --- /dev/null +++ b/sqlc/queries/booking_links.sql @@ -0,0 +1,23 @@ +-- name: CreateBookingLink :one +INSERT INTO booking_links (id, calendar_id, token, duration_minutes, buffer_minutes, timezone, working_hours, active) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: GetBookingLinkByToken :one +SELECT * FROM booking_links +WHERE token = $1; + +-- name: GetBookingLinkByCalendar :one +SELECT * FROM booking_links +WHERE calendar_id = $1; + +-- name: UpdateBookingLink :one +UPDATE booking_links +SET duration_minutes = COALESCE($2, duration_minutes), + buffer_minutes = COALESCE($3, buffer_minutes), + timezone = COALESCE($4, timezone), + working_hours = COALESCE($5, working_hours), + active = COALESCE($6, active), + updated_at = now() +WHERE id = $1 +RETURNING *; diff --git a/sqlc/queries/calendar_members.sql b/sqlc/queries/calendar_members.sql new file mode 100644 index 0000000..12942f9 --- /dev/null +++ b/sqlc/queries/calendar_members.sql @@ -0,0 +1,23 @@ +-- name: UpsertCalendarMember :exec +INSERT INTO calendar_members (calendar_id, user_id, role) +VALUES ($1, $2, $3) +ON CONFLICT (calendar_id, user_id) DO UPDATE SET role = $3; + +-- name: GetCalendarMemberRole :one +SELECT role FROM calendar_members +WHERE calendar_id = $1 AND user_id = $2; + +-- name: ListCalendarMembers :many +SELECT cm.user_id, u.email, cm.role +FROM calendar_members cm +JOIN users u ON u.id = cm.user_id +WHERE cm.calendar_id = $1 AND u.deleted_at IS NULL +ORDER BY cm.role ASC; + +-- name: DeleteCalendarMember :exec +DELETE FROM calendar_members +WHERE calendar_id = $1 AND user_id = $2; + +-- name: DeleteAllCalendarMembers :exec +DELETE FROM calendar_members +WHERE calendar_id = $1; diff --git a/sqlc/queries/calendars.sql b/sqlc/queries/calendars.sql new file mode 100644 index 0000000..ff6d7f8 --- /dev/null +++ b/sqlc/queries/calendars.sql @@ -0,0 +1,33 @@ +-- name: CreateCalendar :one +INSERT INTO calendars (id, owner_id, name, color, is_public) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, owner_id, name, color, is_public, public_token, created_at, updated_at; + +-- name: GetCalendarByID :one +SELECT id, owner_id, name, color, is_public, public_token, created_at, updated_at +FROM calendars +WHERE id = $1 AND deleted_at IS NULL; + +-- name: ListCalendarsByUser :many +SELECT c.id, c.owner_id, c.name, c.color, c.is_public, c.created_at, c.updated_at, cm.role +FROM calendars c +JOIN calendar_members cm ON cm.calendar_id = c.id +WHERE cm.user_id = $1 AND c.deleted_at IS NULL +ORDER BY c.created_at ASC; + +-- name: UpdateCalendar :one +UPDATE calendars +SET name = COALESCE(sqlc.narg('name')::TEXT, name), + color = COALESCE(sqlc.narg('color')::TEXT, color), + is_public = COALESCE(sqlc.narg('is_public')::BOOLEAN, is_public), + updated_at = now() +WHERE id = @id AND deleted_at IS NULL +RETURNING id, owner_id, name, color, is_public, public_token, created_at, updated_at; + +-- name: SoftDeleteCalendar :exec +UPDATE calendars SET deleted_at = now(), updated_at = now() +WHERE id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteCalendarsByOwner :exec +UPDATE calendars SET deleted_at = now(), updated_at = now() +WHERE owner_id = $1 AND deleted_at IS NULL; diff --git a/sqlc/queries/contacts.sql b/sqlc/queries/contacts.sql new file mode 100644 index 0000000..ece4959 --- /dev/null +++ b/sqlc/queries/contacts.sql @@ -0,0 +1,46 @@ +-- name: CreateContact :one +INSERT INTO contacts (id, owner_id, first_name, last_name, email, phone, company, notes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: GetContactByID :one +SELECT * FROM contacts +WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL; + +-- name: ListContacts :many +SELECT * FROM contacts +WHERE owner_id = @owner_id + AND deleted_at IS NULL + AND ( + sqlc.narg('search')::TEXT IS NULL + OR first_name ILIKE '%' || sqlc.narg('search')::TEXT || '%' + OR last_name ILIKE '%' || sqlc.narg('search')::TEXT || '%' + OR email ILIKE '%' || sqlc.narg('search')::TEXT || '%' + OR company ILIKE '%' || sqlc.narg('search')::TEXT || '%' + ) + AND ( + sqlc.narg('cursor_time')::TIMESTAMPTZ IS NULL + OR (created_at, id) > (sqlc.narg('cursor_time')::TIMESTAMPTZ, sqlc.narg('cursor_id')::UUID) + ) +ORDER BY created_at ASC, id ASC +LIMIT @lim; + +-- name: UpdateContact :one +UPDATE contacts +SET first_name = COALESCE(sqlc.narg('first_name'), first_name), + last_name = COALESCE(sqlc.narg('last_name'), last_name), + email = COALESCE(sqlc.narg('email'), email), + phone = COALESCE(sqlc.narg('phone'), phone), + company = COALESCE(sqlc.narg('company'), company), + notes = COALESCE(sqlc.narg('notes'), notes), + updated_at = now() +WHERE id = @id AND owner_id = @owner_id AND deleted_at IS NULL +RETURNING *; + +-- name: SoftDeleteContact :exec +UPDATE contacts SET deleted_at = now(), updated_at = now() +WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL; + +-- name: SoftDeleteContactsByOwner :exec +UPDATE contacts SET deleted_at = now(), updated_at = now() +WHERE owner_id = $1 AND deleted_at IS NULL; diff --git a/sqlc/queries/event_exceptions.sql b/sqlc/queries/event_exceptions.sql new file mode 100644 index 0000000..dc13320 --- /dev/null +++ b/sqlc/queries/event_exceptions.sql @@ -0,0 +1,10 @@ +-- name: ListExceptionsByEvent :many +SELECT id, event_id, exception_date, action +FROM event_exceptions +WHERE event_id = $1 +ORDER BY exception_date ASC; + +-- name: CreateEventException :one +INSERT INTO event_exceptions (id, event_id, exception_date, action) +VALUES ($1, $2, $3, $4) +RETURNING id, event_id, exception_date, action; diff --git a/sqlc/queries/events.sql b/sqlc/queries/events.sql new file mode 100644 index 0000000..b23f60f --- /dev/null +++ b/sqlc/queries/events.sql @@ -0,0 +1,98 @@ +-- name: CreateEvent :one +INSERT INTO events (id, calendar_id, title, description, location, start_time, end_time, timezone, all_day, recurrence_rule, tags, created_by, updated_by) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +RETURNING *; + +-- name: GetEventByID :one +SELECT * FROM events +WHERE id = $1 AND deleted_at IS NULL; + +-- name: ListEventsInRange :many +SELECT e.* FROM events e +JOIN calendar_members cm ON cm.calendar_id = e.calendar_id +WHERE cm.user_id = @user_id + AND e.deleted_at IS NULL + AND e.start_time < @range_end + AND e.end_time > @range_start + AND (sqlc.narg('calendar_id')::UUID IS NULL OR e.calendar_id = sqlc.narg('calendar_id')::UUID) + AND (sqlc.narg('search')::TEXT IS NULL OR (e.title ILIKE '%' || sqlc.narg('search')::TEXT || '%' OR e.description ILIKE '%' || sqlc.narg('search')::TEXT || '%')) + AND (sqlc.narg('tag')::TEXT IS NULL OR sqlc.narg('tag')::TEXT = ANY(e.tags)) + AND ( + sqlc.narg('cursor_time')::TIMESTAMPTZ IS NULL + OR (e.start_time, e.id) > (sqlc.narg('cursor_time')::TIMESTAMPTZ, sqlc.narg('cursor_id')::UUID) + ) +ORDER BY e.start_time ASC, e.id ASC +LIMIT @lim; + +-- name: ListRecurringEventsInRange :many +SELECT e.* FROM events e +JOIN calendar_members cm ON cm.calendar_id = e.calendar_id +WHERE cm.user_id = @user_id + AND e.deleted_at IS NULL + AND e.recurrence_rule IS NOT NULL + AND e.start_time <= @range_end + AND (sqlc.narg('calendar_id')::UUID IS NULL OR e.calendar_id = sqlc.narg('calendar_id')::UUID) +ORDER BY e.start_time ASC; + +-- name: UpdateEvent :one +UPDATE events +SET title = COALESCE(sqlc.narg('title'), title), + description = COALESCE(sqlc.narg('description'), description), + location = COALESCE(sqlc.narg('location'), location), + start_time = COALESCE(sqlc.narg('start_time'), start_time), + end_time = COALESCE(sqlc.narg('end_time'), end_time), + timezone = COALESCE(sqlc.narg('timezone'), timezone), + all_day = COALESCE(sqlc.narg('all_day'), all_day), + recurrence_rule = sqlc.narg('recurrence_rule'), + tags = COALESCE(sqlc.narg('tags'), tags), + updated_by = @updated_by, + updated_at = now() +WHERE id = @id AND deleted_at IS NULL +RETURNING *; + +-- name: SoftDeleteEvent :exec +UPDATE events SET deleted_at = now(), updated_at = now() +WHERE id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteEventsByCalendar :exec +UPDATE events SET deleted_at = now(), updated_at = now() +WHERE calendar_id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteEventsByCreator :exec +UPDATE events SET deleted_at = now(), updated_at = now() +WHERE created_by = $1 AND deleted_at IS NULL; + +-- name: CheckEventOverlap :one +SELECT EXISTS( + SELECT 1 FROM events + WHERE calendar_id = $1 + AND deleted_at IS NULL + AND start_time < $3 + AND end_time > $2 +) AS overlap; + +-- name: CheckEventOverlapForUpdate :one +SELECT EXISTS( + SELECT 1 FROM events + WHERE calendar_id = $1 + AND deleted_at IS NULL + AND start_time < $3 + AND end_time > $2 + FOR UPDATE +) AS overlap; + +-- name: ListEventsByCalendarInRange :many +SELECT * FROM events +WHERE calendar_id = $1 + AND deleted_at IS NULL + AND start_time < $3 + AND end_time > $2 +ORDER BY start_time ASC; + +-- name: ListRecurringEventsByCalendar :many +SELECT * FROM events +WHERE calendar_id = $1 + AND deleted_at IS NULL + AND recurrence_rule IS NOT NULL + AND start_time <= $2 +ORDER BY start_time ASC; diff --git a/sqlc/queries/refresh_tokens.sql b/sqlc/queries/refresh_tokens.sql new file mode 100644 index 0000000..c4d6d76 --- /dev/null +++ b/sqlc/queries/refresh_tokens.sql @@ -0,0 +1,16 @@ +-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, token_hash, expires_at, revoked_at, created_at; + +-- name: GetRefreshTokenByHash :one +SELECT id, user_id, token_hash, expires_at, revoked_at, created_at +FROM refresh_tokens +WHERE token_hash = $1 AND revoked_at IS NULL; + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1; + +-- name: RevokeAllUserRefreshTokens :exec +UPDATE refresh_tokens SET revoked_at = now() +WHERE user_id = $1 AND revoked_at IS NULL; diff --git a/sqlc/queries/reminders.sql b/sqlc/queries/reminders.sql new file mode 100644 index 0000000..8d99ffd --- /dev/null +++ b/sqlc/queries/reminders.sql @@ -0,0 +1,18 @@ +-- name: CreateReminder :one +INSERT INTO event_reminders (id, event_id, minutes_before) +VALUES ($1, $2, $3) +RETURNING id, event_id, minutes_before; + +-- name: ListRemindersByEvent :many +SELECT id, event_id, minutes_before +FROM event_reminders +WHERE event_id = $1 +ORDER BY minutes_before ASC; + +-- name: DeleteReminder :exec +DELETE FROM event_reminders +WHERE id = $1 AND event_id = $2; + +-- name: DeleteRemindersByEvent :exec +DELETE FROM event_reminders +WHERE event_id = $1; diff --git a/sqlc/queries/users.sql b/sqlc/queries/users.sql new file mode 100644 index 0000000..30fc435 --- /dev/null +++ b/sqlc/queries/users.sql @@ -0,0 +1,25 @@ +-- name: CreateUser :one +INSERT INTO users (id, email, password_hash, timezone) +VALUES ($1, $2, $3, $4) +RETURNING id, email, password_hash, timezone, is_active, created_at, updated_at; + +-- name: GetUserByID :one +SELECT id, email, password_hash, timezone, is_active, created_at, updated_at +FROM users +WHERE id = $1 AND deleted_at IS NULL; + +-- name: GetUserByEmail :one +SELECT id, email, password_hash, timezone, is_active, created_at, updated_at +FROM users +WHERE email = $1 AND deleted_at IS NULL; + +-- name: UpdateUser :one +UPDATE users +SET timezone = COALESCE(sqlc.narg('timezone')::TEXT, timezone), + updated_at = now() +WHERE id = @id AND deleted_at IS NULL +RETURNING id, email, password_hash, timezone, is_active, created_at, updated_at; + +-- name: SoftDeleteUser :exec +UPDATE users SET deleted_at = now(), is_active = false, updated_at = now() +WHERE id = $1 AND deleted_at IS NULL; diff --git a/sqlc/schema.sql b/sqlc/schema.sql new file mode 100644 index 0000000..088dc56 --- /dev/null +++ b/sqlc/schema.sql @@ -0,0 +1,174 @@ +-- Calendar & Contacts API Schema + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL; + +-- Refresh Tokens +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id); + +-- API Keys +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_api_keys_user_id ON api_keys (user_id); + +-- Calendars +CREATE TABLE calendars ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#3B82F6', + is_public BOOLEAN NOT NULL DEFAULT false, + public_token TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_calendars_owner_id ON calendars (owner_id); + +-- Calendar Members +CREATE TABLE calendar_members ( + calendar_id UUID NOT NULL REFERENCES calendars(id), + user_id UUID NOT NULL REFERENCES users(id), + role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')), + PRIMARY KEY (calendar_id, user_id) +); + +-- Events +CREATE TABLE events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + calendar_id UUID NOT NULL REFERENCES calendars(id), + title TEXT NOT NULL, + description TEXT, + location TEXT, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + all_day BOOLEAN NOT NULL DEFAULT false, + recurrence_rule TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + created_by UUID NOT NULL REFERENCES users(id), + updated_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_events_calendar_start ON events (calendar_id, start_time); +CREATE INDEX idx_events_start_time ON events (start_time); +CREATE INDEX idx_events_tags ON events USING GIN (tags); + +-- Event Reminders +CREATE TABLE event_reminders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + minutes_before INTEGER NOT NULL CHECK (minutes_before >= 0 AND minutes_before <= 10080) +); + +CREATE INDEX idx_event_reminders_event_id ON event_reminders (event_id); + +-- Event Attendees +CREATE TABLE event_attendees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + user_id UUID REFERENCES users(id), + email TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'tentative')) +); + +CREATE INDEX idx_event_attendees_event_id ON event_attendees (event_id); + +-- Event Exceptions (for recurrence) +CREATE TABLE event_exceptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + exception_date DATE NOT NULL, + action TEXT NOT NULL DEFAULT 'skip' CHECK (action IN ('skip')) +); + +CREATE INDEX idx_event_exceptions_event_id ON event_exceptions (event_id); + +-- Event Attachments +CREATE TABLE event_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id), + file_url TEXT NOT NULL +); + +CREATE INDEX idx_event_attachments_event_id ON event_attachments (event_id); + +-- Contacts +CREATE TABLE contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id), + first_name TEXT, + last_name TEXT, + email TEXT, + phone TEXT, + company TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_contacts_owner_id ON contacts (owner_id); +CREATE INDEX idx_contacts_search ON contacts (owner_id, first_name, last_name, email, company); + +-- Booking Links +CREATE TABLE booking_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + calendar_id UUID NOT NULL REFERENCES calendars(id), + token TEXT NOT NULL UNIQUE, + duration_minutes INTEGER NOT NULL, + buffer_minutes INTEGER NOT NULL DEFAULT 0, + timezone TEXT NOT NULL DEFAULT 'UTC', + working_hours JSONB NOT NULL DEFAULT '{}', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_booking_links_token ON booking_links (token); + +-- Audit Logs +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + action TEXT NOT NULL, + user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_logs_entity ON audit_logs (entity_type, entity_id); diff --git a/sqlc/sqlc.yaml b/sqlc/sqlc.yaml new file mode 100644 index 0000000..8be5f8f --- /dev/null +++ b/sqlc/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "queries/" + schema: "schema.sql" + gen: + go: + package: "repository" + out: "../internal/repository" + sql_package: "pgx/v5" + emit_json_tags: true + emit_empty_slices: true