first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-02-28 02:17:55 +00:00
commit 41f6ae916f
92 changed files with 12332 additions and 0 deletions

12
.env.example Normal file
View File

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

48
.gitignore vendored Normal file
View File

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

279
README.md Normal file
View File

@@ -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 <access_token>
```
### 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 <access_token>" \
-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: <token>
```
## 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

396
SKILL.md Normal file
View File

@@ -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=<api-key-token>
ACCESS_TOKEN=<current-jwt-access-token>
REFRESH_TOKEN=<current-jwt-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 <ACCESS_TOKEN>`.
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 <token>` | 15 min access / 7-30 day refresh | Interactive sessions |
| API Key | `X-API-Key: <token>` | 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": "<refresh_token>"}
```
Returns new `access_token` and `refresh_token`. Update `calendarcredentials.txt`.
### Create an API key (recommended for agents)
```
POST /api-keys
Authorization: Bearer <access_token>
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=<uuid>
```
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": "<uuid>",
"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": "<uuid>"}]}
```
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=<uuid>&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=<opaque-string>
```
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=<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=<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`.

899
about/details.md Normal file
View File

@@ -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 <access_token>
API key:
* X-API-Key: <api_key_token>
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/)<token>",
"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

501
about/logic.md Normal file
View File

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

389
about/overview.md Normal file
View File

@@ -0,0 +1,389 @@
# Calendar & Contacts API
## 1. Purpose
This system is a productiongrade Calendar and Contacts REST API written in Go. It is designed for:
* Human users (web/mobile frontends)
* AI agents (programmatic automation)
* Future SaaS expansion
* Highintegrity multiuser environments
The API must be stateless, secure, permissionenforced, timezonesafe, 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 (730 days)
JWT payload:
{
"user_id": "uuid",
"exp": unix_timestamp
}
All protected endpoints require:
Authorization: Bearer <token>
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: <token>
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.

139
cmd/server/main.go Normal file
View File

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

31
go.mod Normal file
View File

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

123
go.sum Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar & Contacts API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
</script>
</body>
</html>`
func DocsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(swaggerHTML))
}

View File

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

View File

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

View File

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

View File

@@ -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": [] }
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

128
internal/api/routes.go Normal file
View File

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

67
internal/auth/jwt.go Normal file
View File

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

59
internal/config/config.go Normal file
View File

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

102
internal/middleware/auth.go Normal file
View File

@@ -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[:])
}

View File

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

View File

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

52
internal/models/errors.go Normal file
View File

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

164
internal/models/models.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

32
internal/repository/db.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

30
internal/service/audit.go Normal file
View File

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

256
internal/service/auth.go Normal file
View File

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

View File

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

264
internal/service/booking.go Normal file
View File

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

View File

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

172
internal/service/contact.go Normal file
View File

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

583
internal/service/event.go Normal file
View File

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

View File

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

92
internal/service/user.go Normal file
View File

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

View File

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

88
internal/utils/pgtype.go Normal file
View File

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

View File

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

View File

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

129
llms.txt Normal file
View File

@@ -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 <access_token>` - short-lived (15 min), obtained via login/register
- API Key: `X-API-Key: <token>` - 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

View File

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

View File

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

23
sqlc/queries/api_keys.sql Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
-- name: CreateAuditLog :exec
INSERT INTO audit_logs (entity_type, entity_id, action, user_id)
VALUES ($1, $2, $3, $4);

View File

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

View File

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

View File

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

46
sqlc/queries/contacts.sql Normal file
View File

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

View File

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

98
sqlc/queries/events.sql Normal file
View File

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

View File

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

View File

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

25
sqlc/queries/users.sql Normal file
View File

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

174
sqlc/schema.sql Normal file
View File

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

12
sqlc/sqlc.yaml Normal file
View File

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