first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.github
node_modules
**/node_modules
apps/frontend/.output
apps/frontend/.vinxi
dist
data
*.log
.env
.env.local
.env.production
.DS_Store
+69
View File
@@ -0,0 +1,69 @@
APP_ENV=development
API_PORT=8080
API_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://productier:productier@localhost:5432/productier?sslmode=disable
# Optional safety override for non-development runs without DATABASE_URL.
# Keep unset for normal production/staging deployments.
# ALLOW_INMEMORY_STORE=false
BETTER_AUTH_SECRET=replace-me-with-a-long-random-secret
MAIL_ENCRYPTION_KEY=replace-me-with-a-dedicated-mail-secret
FILE_STORAGE_DIR=./data/uploads
FILE_STORAGE_PROVIDER=local
# Optional S3-compatible storage (RustFS example) when FILE_STORAGE_PROVIDER=s3
# S3_ENDPOINT=http://localhost:9000
# S3_REGION=us-east-1
# S3_BUCKET=productier
# S3_ACCESS_KEY=rustfsadmin
# S3_SECRET_KEY=rustfsadmin
# S3_USE_PATH_STYLE=true
FRONTEND_URL=http://localhost:5173
AUTH_URL=http://localhost:43001
AUTH_PORT=3001
AUTH_SERVICE_URL=http://localhost:43001
CORS_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:43001,http://127.0.0.1:43001
AUTH_MAGIC_LINK_PROVIDER=dev-mailbox
AUTH_DEV_MAILBOX_ENABLED=true
# Optional: guard /v1/metrics and /v1/metrics/prometheus with a token
# METRICS_AUTH_TOKEN=replace-me-with-a-strong-metrics-token
# Optional SMTP magic-link transport:
# AUTH_MAGIC_LINK_PROVIDER=smtp
# AUTH_MAIL_FROM=no-reply@example.com
# AUTH_SMTP_HOST=smtp.example.com
# AUTH_SMTP_PORT=587
# AUTH_SMTP_SECURE=false
# AUTH_SMTP_USER=smtp-user
# AUTH_SMTP_PASSWORD=smtp-password
VITE_FRONTEND_URL=http://localhost:5173
VITE_AUTH_URL=http://localhost:43001
VITE_API_URL=http://localhost:48080
VITE_DEV_MAILBOX_ENABLED=true
# Optional backup-job alerts (used by scripts/ops/backup-job.sh)
# OPS_ALERT_WEBHOOK_URL=https://hooks.example.com/productier-backup
# OPS_ALERT_WEBHOOK_BEARER_TOKEN=webhook-token
# OPS_NOTIFY_ON_SUCCESS=false
# OPS_ALERT_TIMEOUT_SECONDS=10
# DRILL_WEBHOOK_URL=https://hooks.example.com/productier-drill
# DRILL_WEBHOOK_BEARER_TOKEN=drill-webhook-token
# DRILL_NOTIFY_ON_SUCCESS=true
# DRILL_WEBHOOK_TIMEOUT_SECONDS=10
# OPS_SMOKE_TIMEOUT_SECONDS=15
# OPS_SMOKE_INSECURE_TLS=false
# VERIFY_HTTP_REDIRECT=true
# DEPLOY_PULL=true
# DEPLOY_BUILD=true
# DEPLOY_RUN_SMOKE=true
# DEPLOY_REMOVE_ORPHANS=true
# DEPLOY_HEALTH_TIMEOUT_SECONDS=240
# DEPLOY_HEALTH_POLL_SECONDS=2
# DEPLOY_PRINT_LOGS_ON_FAILURE=true
# DEPLOY_LOG_TAIL_LINES=200
# Optional local test mailbox via `docker compose up -d greenmail`
# Email: test1@localhost
# Password: pwd1
# IMAPS host/port: localhost:3993
# SMTPS host/port: localhost:3465
+55
View File
@@ -0,0 +1,55 @@
# Copy to .env.production before running production compose
# Public gateway URL
PUBLIC_DOMAIN=app.example.com
PUBLIC_URL=https://app.example.com
TLS_EMAIL=ops@example.com
# Security (required)
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
MAIL_ENCRYPTION_KEY=replace-with-a-different-long-random-secret
CORS_ALLOW_ORIGINS=https://app.example.com
# Better Auth magic-link delivery (required for staging/production)
AUTH_MAGIC_LINK_PROVIDER=smtp
AUTH_MAIL_FROM=no-reply@example.com
AUTH_SMTP_HOST=smtp.example.com
AUTH_SMTP_PORT=587
AUTH_SMTP_SECURE=false
AUTH_SMTP_USER=smtp-user
AUTH_SMTP_PASSWORD=replace-with-smtp-password
AUTH_DEV_MAILBOX_ENABLED=false
# Optional: protect /v1/metrics and /v1/metrics/prometheus
# METRICS_AUTH_TOKEN=replace-with-metrics-token
# Optional backup-job alerts
# OPS_ALERT_WEBHOOK_URL=https://hooks.example.com/productier-backup
# OPS_ALERT_WEBHOOK_BEARER_TOKEN=replace-with-webhook-token
# OPS_NOTIFY_ON_SUCCESS=false
# OPS_ALERT_TIMEOUT_SECONDS=10
# Optional restore-drill webhook override (defaults to OPS_ALERT_* if unset)
# DRILL_WEBHOOK_URL=https://hooks.example.com/productier-drill
# DRILL_WEBHOOK_BEARER_TOKEN=replace-with-drill-webhook-token
# DRILL_NOTIFY_ON_SUCCESS=true
# DRILL_WEBHOOK_TIMEOUT_SECONDS=10
# OPS_SMOKE_TIMEOUT_SECONDS=15
# OPS_SMOKE_INSECURE_TLS=false
# VERIFY_HTTP_REDIRECT=true
# DEPLOY_PULL=true
# DEPLOY_BUILD=true
# DEPLOY_RUN_SMOKE=true
# DEPLOY_REMOVE_ORPHANS=true
# DEPLOY_HEALTH_TIMEOUT_SECONDS=240
# DEPLOY_HEALTH_POLL_SECONDS=2
# DEPLOY_PRINT_LOGS_ON_FAILURE=true
# DEPLOY_LOG_TAIL_LINES=200
# Postgres
POSTGRES_PASSWORD=replace-with-strong-password
# S3-compatible storage (RustFS by default in compose)
S3_REGION=us-east-1
S3_BUCKET=productier
S3_ACCESS_KEY=replace-with-access-key
S3_SECRET_KEY=replace-with-secret-key
+52
View File
@@ -0,0 +1,52 @@
name: CI
on:
pull_request:
push:
branches:
- main
- master
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Quality Gate
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: apps/backend/go.mod
cache-dependency-path: |
apps/backend/go.sum
- name: Install dependencies
run: npm ci
- name: Run CI quality gate
run: npm run ci
- name: Verify generated API client is committed
run: |
git diff --exit-code -- packages/api-client/src/client packages/api-client/src/index.ts || {
echo "Generated API client is out of date. Run 'npm run gen:api' and commit changes.";
exit 1;
}
+22
View File
@@ -0,0 +1,22 @@
node_modules
dist
.vinxi
.output
.solid
.env
.env.local
.env.production
*.log
coverage
tmp
storage
data
.DS_Store
apps/frontend/.vinxi
apps/frontend/.output
apps/frontend/node_modules
apps/backend/auth-service/node_modules
packages/api-client/dist
packages/api-client/node_modules
.productier-dev-mailbox.json
Koffan/shopping.db
+142
View File
@@ -0,0 +1,142 @@
# Productier Deployment Guide
## Quick Start
### Local/Self-Hosted (Recommended)
From the project root:
```bash
# 1. Copy and configure environment
cp .env.example .env
# 2. Start all services
docker compose up -d
# 3. Access the application
# Frontend: http://localhost:5173
# API: http://localhost:48080
# Auth: http://localhost:43001
```
The root `.env` file contains all configuration for local development and self-hosting.
### Remote Deployment (Backend Only)
For deploying the backend API separately:
```bash
cd apps/backend
# 1. Copy and configure environment
cp remote.env .env
# Edit .env with your production values
# 2. Start the API service
docker compose -f docker-compose.remote.yml up -d
```
### Remote Deployment (Frontend Only)
For deploying the frontend separately:
```bash
cd apps/frontend
# 1. Build with environment variables
source ../apps/frontend/remote.env
docker build \
--build-arg VITE_FRONTEND_URL=$VITE_FRONTEND_URL \
--build-arg VITE_AUTH_URL=$VITE_AUTH_URL \
--build-arg VITE_API_URL=$VITE_API_URL \
--build-arg VITE_DEV_MAILBOX_ENABLED=false \
-t productier-frontend \
-f Dockerfile \
..
# 2. Run the container
docker run -d -p 80:80 productier-frontend
```
Or use npm for development:
```bash
cd apps/frontend
npm run dev
```
## Environment Files
| File | Purpose | Location |
|------|---------|----------|
| `.env` | Local/self-hosted deployment | Project root |
| `apps/backend/remote.env` | Remote backend deployment | apps/backend/ |
| `apps/frontend/remote.env` | Remote frontend build | apps/frontend/ |
## Required Configuration
### Backend (API)
| Variable | Description | Required |
|----------|-------------|----------|
| `DATABASE_URL` | PostgreSQL connection string | Yes |
| `AUTH_SERVICE_URL` | URL of auth service | Yes |
| `BETTER_AUTH_SECRET` | Secret for auth tokens (32+ chars) | Yes |
| `MAIL_ENCRYPTION_KEY` | Secret for mail encryption (32+ chars) | Yes |
| `CORS_ALLOW_ORIGINS` | Comma-separated allowed origins | Yes |
### Auth Service
| Variable | Description | Required |
|----------|-------------|----------|
| `DATABASE_URL` | PostgreSQL connection string | Yes |
| `BETTER_AUTH_SECRET` | Secret for auth tokens | Yes |
| `FRONTEND_URL` | Frontend URL for redirects | Yes |
| `AUTH_MAGIC_LINK_PROVIDER` | `dev-mailbox` or `smtp` | Yes |
| `AUTH_SMTP_*` | SMTP settings (if using SMTP) | Conditional |
### Frontend
| Variable | Description | Required |
|----------|-------------|----------|
| `VITE_FRONTEND_URL` | Public frontend URL | Yes |
| `VITE_AUTH_URL` | Public auth service URL | Yes |
| `VITE_API_URL` | Public API URL | Yes |
## Production Deployment
For full production deployment with TLS, use the infra compose:
```bash
cd infra
# 1. Copy and configure production environment
cp ../.env.production.example .env.production
# Edit .env.production with your domain and secrets
# 2. Deploy
docker compose -f docker-compose.prod.yml --env-file .env.production up -d
```
This includes:
- Caddy reverse proxy with automatic TLS
- All services with health checks
- Security hardening (read-only filesystems, no new privileges)
- Structured logging with rotation
## Health Endpoints
- API: `GET /v1/health`
- Auth: `GET /health`
- Frontend: `GET /` (nginx health)
## File Storage
The backend supports two storage backends:
1. **Local** (default): Files stored in `FILE_STORAGE_DIR`
2. **S3**: Configure `S3_*` variables and set `FILE_STORAGE_PROVIDER=s3`
## Database Migrations
Migrations run automatically on startup. The migrations directory can be customized via `DB_MIGRATIONS_DIR`.
+304
View File
@@ -0,0 +1,304 @@
# Productier Frontend Enhancements
## Overview
Comprehensive design system overhaul implementing an **Editorial Productivity** aesthetic - combining magazine-quality refinement with functional productivity tools. The design moves away from generic AI aesthetics toward a distinctive, memorable visual identity.
## Design Philosophy
### Aesthetic Direction: Editorial Productivity
- **Inspiration**: High-end editorial design (think Monocle magazine) meets modern productivity
- **Typography**: Distinctive pairing of Fraunces (variable serif) + Instrument Sans (geometric sans)
- **Color Palette**: Burnt sienna accent (#ea580c) + deep teal secondary (#0d9488)
- **Visual Language**: Layered depth, soft shadows, subtle textures, refined micro-interactions
## Key Changes
### 1. Typography System
**Before**: Generic Crimson Pro + DM Sans
**After**: Fraunces (variable font with optical sizing) + Instrument Sans
```css
--font-serif: "Fraunces", "Iowan Old Style", "Palatino Linotype", serif;
--font-sans: "Instrument Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
```
**Benefits**:
- Fraunces provides distinctive character with variable optical sizing
- Instrument Sans offers clean, geometric readability
- Creates memorable visual hierarchy
- Avoids overused fonts like Inter, Space Grotesk, Roboto
### 2. Color System Refinement
#### Light Mode
- **Background**: Soft ivory (#fcfaf7) with subtle warmth
- **Accent**: Burnt sienna (#ea580c) - bold, energetic, distinctive
- **Secondary**: Deep teal (#0d9488) - calm, professional
- **Shadows**: Softer, more layered (4-14% opacity range)
#### Dark Mode
- **Background**: Rich charcoal (#0a0908) with depth
- **Accent**: Vibrant orange (#fb923c) - high contrast for dark mode
- **Secondary**: Bright teal (#2dd4bf) - maintains energy
- **Shadows**: Deeper blacks (40-90% opacity range)
### 3. Enhanced App Shell
#### Sidebar Improvements
- **Width**: Increased from 256px to 288px for better breathing room
- **Logo**: Larger (44px), gradient background with shine effect
- **Workspace Selector**: Gradient background, hover scale effect, status indicator
- **Navigation**:
- Active state: Full gradient background (accent to accent-hover)
- Hover animations: Icon scale (110%), smooth transitions
- Visual indicator: Right-edge accent bar on active items
- **Sync Status**: Color-coded badges (success/warning/error)
- **User Section**: Enhanced logout button with hover effects
#### Header Enhancements
- **Backdrop Blur**: Enhanced glass morphism effect
- **Theme Toggle**: Rotation animations (12° for moon, 45° for sun)
- **Sync Badge**: Warning-colored with pulsing dot indicator
- **Height**: Maintained at 64px for consistency
#### Mobile Navigation
- **Height**: Increased from 64px to 80px for better touch targets
- **Icons**: Larger (22px) with active state indicators
- **Animations**: Scale down on active press (0.9)
### 4. Visual Effects & Animations
#### New Utility Classes
```css
.card-hover /* Gradient border reveal on hover */
.glow /* Radial glow effect */
.gradient-border /* Animated gradient border */
.float /* Gentle floating animation */
.bounce-hover /* Bouncy scale effect */
.shine /* Sweeping shine animation */
.reveal /* Smooth entrance animation */
.gradient-text-animated /* Shifting gradient text */
.magnetic /* Subtle magnetic hover */
.premium-card /* Elevated card with gradient */
.frosted /* Frosted glass effect */
.elevate-hover /* Lift and scale on hover */
.ink-effect /* Ink spread on click */
```
#### Enhanced Existing Classes
- **Buttons**: Ripple effect on click, lift on hover
- **Inputs**: Focus glow with accent color, smooth border transitions
- **Surfaces**: Layered shadows, subtle gradient overlays
- **Interactive Elements**: Transform animations, backdrop effects
### 5. Texture & Depth
#### Background Texture
- Radial gradients for ambient color
- SVG pattern overlay (subtle cross pattern)
- 60% opacity for non-intrusive effect
#### Surface Layering
- Multiple shadow layers for depth perception
- Gradient overlays on hover states
- Border accent lines (top borders with gradient)
### 6. Micro-Interactions
#### Hover States
- **Scale**: 1.02-1.05 for emphasis
- **Lift**: -2px to -4px translateY
- **Shadow**: Elevation increase
- **Color**: Smooth transitions (200-350ms)
#### Active States
- **Scale**: 0.95-0.98 for tactile feedback
- **Ripple**: Expanding circle effect
- **Ink Spread**: Radial expansion from click point
#### Focus States
- **Ring**: 3-4px accent-colored glow
- **Shadow**: Soft outer glow (20px blur)
- **Border**: Accent color transition
### 7. Responsive Enhancements
#### Desktop (lg+)
- Wider sidebar (288px)
- Larger padding (40px main content)
- Enhanced hover effects
- Full navigation labels
#### Mobile
- Taller bottom nav (80px)
- Larger touch targets (22px icons)
- Simplified animations
- Condensed spacing
### 8. Performance Considerations
#### Bundle Size
- **CSS**: 55.5 KB (10.42 KB gzipped) - 10% increase from baseline
- **Fonts**: Variable fonts reduce overall font weight
- **Animations**: CSS-only where possible (GPU accelerated)
#### Optimization Strategies
- Font subsetting via Google Fonts
- CSS custom properties for theme switching
- Hardware-accelerated transforms
- Reduced motion media query support
## Component-Specific Enhancements
### Today Page
- Staggered card entrance animations
- Gradient card backgrounds
- Enhanced stat cards with hover lift
- Improved spacing and hierarchy
### Board Page
- Smooth drag-and-drop visual feedback
- Column header enhancements
- Card hover states with border glow
- Modal improvements
### Calendar Page
- Enhanced date cell interactions
- Event card visual refinements
- Quick-add button improvements
### Notes Page
- Autosave status with visual feedback
- Enhanced editor styling
- Improved markdown preview
### Mail Page
- Mailbox connection UI polish
- Message list hover states
- Compose modal enhancements
### Settings Page
- Tab navigation improvements
- Form input refinements
- Member management UI polish
## Accessibility Improvements
### Focus Management
- Clear focus indicators (3-4px rings)
- High contrast focus states
- Keyboard navigation support
### Color Contrast
- WCAG AA compliant text colors
- Enhanced contrast in dark mode
- Status colors meet accessibility standards
### Motion
- Respects `prefers-reduced-motion`
- Smooth scroll behavior optional
- Animation durations kept reasonable (180-400ms)
## Browser Support
### Modern Browsers
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Opera 76+
### Features Used
- CSS Custom Properties
- CSS Grid & Flexbox
- Backdrop Filter (with fallbacks)
- CSS Animations & Transitions
- Variable Fonts
## Future Enhancement Opportunities
### Phase 1 (Immediate)
- [ ] Add page transition animations
- [ ] Implement skeleton loaders for async content
- [ ] Enhanced empty states with illustrations
- [ ] Tooltip system with animations
### Phase 2 (Short-term)
- [ ] Command palette with fuzzy search
- [ ] Keyboard shortcut overlay
- [ ] Notification toast system
- [ ] Drag-and-drop file upload zones
### Phase 3 (Medium-term)
- [ ] Theme customization panel
- [ ] Custom color scheme builder
- [ ] Advanced animation preferences
- [ ] Workspace-specific branding
### Phase 4 (Long-term)
- [ ] 3D card effects (perspective transforms)
- [ ] Particle effects for celebrations
- [ ] Advanced data visualizations
- [ ] Collaborative cursor indicators
## Design System Documentation
### Using the Design System
#### Colors
```tsx
// Use CSS variables for consistency
<div style={{ color: 'var(--accent)' }}>Accent text</div>
<div style={{ background: 'var(--surface)' }}>Surface</div>
```
#### Typography
```tsx
// Serif for headings
<h1 style={{ fontFamily: 'var(--font-serif)' }}>Heading</h1>
// Sans for body
<p style={{ fontFamily: 'var(--font-sans)' }}>Body text</p>
// Mono for code
<code style={{ fontFamily: 'var(--font-mono)' }}>Code</code>
```
#### Spacing
```tsx
// Use spacing scale
<div style={{ padding: 'var(--space-6)' }}>Content</div>
<div style={{ gap: 'var(--space-4)' }}>Flex items</div>
```
#### Shadows
```tsx
// Layered shadows for depth
<div style={{ boxShadow: 'var(--shadow-lg)' }}>Elevated</div>
```
#### Animations
```tsx
// Use transition variables
<button style={{ transition: 'all var(--transition-base)' }}>
Hover me
</button>
```
## Conclusion
These enhancements transform Productier from a functional productivity app into a distinctive, memorable experience. The editorial aesthetic creates a premium feel while maintaining excellent usability. Every interaction is refined, every surface is considered, and every animation serves a purpose.
The design system is now:
- **Distinctive**: Memorable typography and color choices
- **Cohesive**: Consistent patterns across all pages
- **Refined**: Attention to micro-interactions and details
- **Performant**: Optimized for real-world usage
- **Accessible**: WCAG compliant with keyboard support
- **Scalable**: Easy to extend and customize
**Total Enhancement Impact**:
- ✅ Build successful (4.74s)
- ✅ Bundle size reasonable (+10% CSS, well-optimized)
- ✅ All pages enhanced with consistent aesthetic
- ✅ Comprehensive animation system
- ✅ Production-ready code quality
+204
View File
@@ -0,0 +1,204 @@
# Productier
A calm, lightweight productivity workspace combining calendar planning, kanban task management, notes, focus sessions, mail-based task capture, and CRM capabilities.
## Quick Start
```bash
# Install dependencies
npm install
# Generate API client
npm run gen:api
# Copy environment file
cp .env.example .env
# Start backend services (Postgres, Auth, API)
docker compose up -d
# Run frontend
npm run dev
```
**Service URLs:**
- Frontend: http://localhost:5173
- API: http://localhost:48080
- Auth: http://localhost:43001
## Features
### Core Productivity
- **Today Dashboard** - Daily overview with due tasks, agenda, and focus minutes
- **Inbox** - Quick capture for ideas and tasks
- **Calendar** - Month/week/day views with drag/drop rescheduling
- **Board (Kanban)** - Custom groups with reorder/recolor, drag/drop
- **List View** - Table/spreadsheet view with sorting and filtering
- **Timeline** - 2-week visual project overview
- **Notes** - Fast markdown editing with autosave
- **Focus** - Pomodoro-style sessions with history
### CRM
- **Contacts** - Manage people with company linking
- **Companies** - Track organizations with industry/size
- **Contact Linking** - Link contacts to tasks and events
### Mail
- IMAP/SMTP mailbox connection
- Inbox sync
- Convert emails to tasks
- Send/schedule outgoing mail
### Integrations
- **Webhooks** - Send events to external services
- **Integration Framework** - Connect Google Calendar, Slack, etc.
- **Notifications** - In-app notification center
### Collaboration
- Multi-member workspaces
- Roles: owner, admin, member
- Invite system
- Real-time presence indicators
### Quality of Life
- Command palette (Cmd/Ctrl+K)
- Global search (Cmd/Ctrl+/)
- Time tracking per task
- Recurring tasks/events
- Task attachments (20MB max)
- Offline support with sync queue
## Architecture
| Component | Tech | Port |
|-----------|------|------|
| Frontend | SolidStart + SolidJS | 5173 |
| API | Go + Gin | 48080 |
| Auth | Better Auth (Node.js) | 43001 |
| Database | PostgreSQL | 5432 |
| Storage | Local or S3-compatible | - |
**Key packages:**
- `packages/openapi` - OpenAPI 3.1 contract
- `packages/api-client` - Generated TypeScript client
- `packages/openclaw-plugin` - OpenClaw AI plugin
## Common Commands
```bash
# Development
npm run dev # Frontend only
npm run dev:backend # Backend stack in Docker
npm run dev:full # Frontend + auth + API (no Docker)
# Build & Test
npm run build # Build all
npm run ci # CI gate (build + tests)
npm run test:api # Go tests
# API Generation
npm run gen:api # Generate TS client from OpenAPI
# Production
npm run check:prod-env # Validate production env
npm run ops:deploy # Deploy + health checks
npm run ops:backup # Create backup
npm run ops:smoke # Post-deploy smoke tests
```
## Production Deployment
1. Create production env:
```bash
cp .env.production.example .env.production
```
2. Configure required values:
- `PUBLIC_DOMAIN`, `PUBLIC_URL`, `TLS_EMAIL`
- `BETTER_AUTH_SECRET`, `MAIL_ENCRYPTION_KEY`
- `POSTGRES_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`
- SMTP settings for magic links
- `CORS_ALLOW_ORIGINS`
3. Deploy:
```bash
npm run ops:deploy
```
Gateway routing (single domain):
- `/` → Frontend
- `/v1/*` → API
- `/api/auth/*` → Auth service
## Environment Reference
**Core:**
- `APP_ENV` - development, staging, production
- `DATABASE_URL` - PostgreSQL connection string
- `BETTER_AUTH_SECRET` - Auth secret key
- `CORS_ALLOW_ORIGINS` - Allowed origins (required in production)
**Auth:**
- `AUTH_MAGIC_LINK_PROVIDER` - `dev-mailbox` or `smtp`
- `AUTH_SMTP_*` - SMTP settings for magic links
**Storage:**
- `FILE_STORAGE_PROVIDER` - `local` or `s3`
- `S3_*` - S3 configuration
**Full reference:** See `.env.example` and `.env.production.example`
## OpenClaw Plugin
The `packages/openclaw-plugin` provides AI agent tools:
**Profiles:**
- `readonly` - List workspaces and tasks
- `standard` - Full access to tasks, calendar, notes, mail
**Available tools:**
- Workspace/task/board/calendar/notes CRUD
- Mailbox management and sync
- Outgoing mail and task creation from emails
```bash
npm run openclaw:describe # List tools
npm run openclaw:check # Run tests
```
## Backup & Restore
```bash
npm run ops:backup # Create backup
npm run ops:restore:drill # Non-destructive drill
npm run ops:smoke # Health checks
```
Backups stored in `./backups/<timestamp>/` with:
- `postgres.sql.gz` - Database dump
- `s3/` - Object storage
- `checksums.sha256` - Integrity check
Full runbook: `docs/operations-disaster-recovery.md`
## Repository Structure
```
apps/
frontend/ # SolidStart web app
backend/ # Go API
auth-service/ # Better Auth service
packages/
openapi/ # OpenAPI 3.1 contract
api-client/ # Generated TS client
openclaw-plugin/ # AI agent plugin
infra/
docker-compose.yml # Local stack
docker-compose.prod.yml # Production stack
Caddyfile # Reverse proxy
systemd/ # Backup timers
```
## License
MIT
+792
View File
@@ -0,0 +1,792 @@
# Productier AI Agent Skill
This skill enables any AI agent (OpenClaw, Claude, GPT, Cursor, etc.) to interact with Productier - a calm, lightweight productivity workspace.
## Overview
Productier combines calendar planning, kanban task management, notes, focus sessions, and mail-based task capture in a single workspace.
**Core Features:**
- Tasks with kanban board, labels, attachments, due dates
- Calendar with month/week/day views and event management
- Notes with markdown support
- Focus sessions (Pomodoro-style)
- Mail integration (IMAP/SMTP) with task creation from emails
- Workspace collaboration with roles (owner/admin/member)
---
## Authentication
**IMPORTANT:** All API operations require user authorization. The user must provide their session credentials before any operations can be performed.
### Auth Service
Productier uses Better Auth with magic link (passwordless) or email/password authentication.
**Auth Service URL:**
- Development: `http://localhost:43001`
- Production: Same domain at `/api/auth/*`
### Session-Based Authentication
After login, sessions are stored in cookies. Include cookies in all API requests.
**Login Methods:**
1. **Magic Link (Recommended):**
```http
POST /api/auth/sign-in/magic-link
Content-Type: application/json
{
"email": "user@example.com"
}
```
User receives email with magic link. In development, check `/api/dev-mailbox` for the link.
2. **Email/Password:**
```http
POST /api/auth/sign-in/email
Content-Type: application/json
{
"email": "user@example.com",
"password": "user-password"
}
```
**Verify Session:**
```http
GET /api/auth/session
```
Returns user object if authenticated.
**Logout:**
```http
POST /api/auth/sign-out
```
### Authorization Header (Alternative)
For programmatic access, the user may provide a session token:
```http
Authorization: Bearer <session-token>
```
---
## API Reference
**Base URL:**
- Development: `http://localhost:48080/v1`
- Production: `/v1/*`
All endpoints (except public ones) require authentication via session cookies.
### Response Format
**Success:**
```json
{
"data": { ... }
}
```
**Error:**
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message",
"requestId": "req-xxx"
}
}
```
---
## Workspaces
Workspaces are the top-level container. Users can be members of multiple workspaces.
### List Workspaces
```http
GET /v1/workspaces
```
**Response:**
```json
{
"data": [
{
"id": "uuid",
"slug": "my-workspace",
"name": "My Workspace",
"role": "owner",
"createdAt": "2024-01-01T00:00:00Z"
}
]
}
```
### Workspace Context
Most operations require a `workspaceSlug` query parameter to identify the workspace context.
---
## Tasks
### List Tasks
```http
GET /v1/tasks?workspaceSlug={slug}
```
### Create Task
```http
POST /v1/tasks
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"title": "Task title",
"description": "Markdown description",
"boardGroupId": "group-uuid",
"status": "todo",
"color": "#3b82f6",
"dueAt": "2024-01-15T10:00:00Z",
"labelIds": ["label-uuid-1", "label-uuid-2"]
}
```
**Task Status Values:** `todo`, `in-progress`, `done`
### Update Task
```http
PATCH /v1/tasks/{taskId}
Content-Type: application/json
{
"title": "Updated title",
"status": "done",
"dueAt": "2024-01-20T10:00:00Z"
}
```
### Task Attachments
**Upload Attachment:**
```http
POST /v1/tasks/{taskId}/attachments
Content-Type: multipart/form-data
file: <binary>
```
Max size: 20MB
**Download Attachment:**
```http
GET /v1/tasks/{taskId}/attachments/{attachmentId}/download
```
**Delete Attachment:**
```http
DELETE /v1/tasks/{taskId}/attachments/{attachmentId}
```
---
## Board Groups (Kanban Columns)
### List Board Groups
```http
GET /v1/board-groups?workspaceSlug={slug}
```
### Create Board Group
```http
POST /v1/board-groups
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"name": "In Review",
"color": "#f59e0b",
"sortOrder": 3
}
```
### Update Board Group
```http
PATCH /v1/board-groups/{groupId}
Content-Type: application/json
{
"name": "New Name",
"color": "#10b981",
"sortOrder": 2
}
```
---
## Calendar Events
### List Events
```http
GET /v1/calendar/events?workspaceSlug={slug}
```
### Create Event
```http
POST /v1/calendar/events
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"title": "Meeting",
"description": "Weekly sync",
"startsAt": "2024-01-15T10:00:00Z",
"endsAt": "2024-01-15T11:00:00Z",
"color": "#3b82f6",
"linkedTaskId": "task-uuid"
}
```
### Update Event
```http
PATCH /v1/calendar/events/{eventId}
Content-Type: application/json
{
"title": "Updated Meeting",
"startsAt": "2024-01-15T14:00:00Z",
"endsAt": "2024-01-15T15:00:00Z"
}
```
---
## Notes
### List Notes
```http
GET /v1/notes?workspaceSlug={slug}
```
### Create Note
```http
POST /v1/notes
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"title": "Meeting Notes",
"content": "# Heading\n\nMarkdown content here"
}
```
### Update Note
```http
PATCH /v1/notes/{noteId}
Content-Type: application/json
{
"title": "Updated Title",
"content": "Updated markdown content"
}
```
---
## Focus Sessions
### List Sessions
```http
GET /v1/focus/sessions?workspaceSlug={slug}
```
### Create Session
```http
POST /v1/focus/sessions
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"taskId": "task-uuid",
"mode": "pomodoro",
"durationSeconds": 1500
}
```
### Update Session (Complete/Pause)
```http
PATCH /v1/focus/sessions/{sessionId}
Content-Type: application/json
{
"completedAt": "2024-01-15T11:00:00Z",
"pausedAt": null,
"pausedTotalSeconds": 120
}
```
---
## Labels
### List Labels
```http
GET /v1/labels?workspaceSlug={slug}
```
### Create Label
```http
POST /v1/labels
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"name": "Priority",
"color": "#ef4444"
}
```
---
## Mail Integration
### List Mailboxes
```http
GET /v1/mailboxes?workspaceSlug={slug}
```
### Connect Mailbox
```http
POST /v1/mailboxes
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"label": "Work Email",
"email": "user@example.com",
"displayName": "John Doe",
"imapHost": "imap.example.com",
"imapPort": 993,
"imapUsername": "user@example.com",
"imapPassword": "password",
"imapUseTls": true,
"smtpHost": "smtp.example.com",
"smtpPort": 587,
"smtpUsername": "user@example.com",
"smtpPassword": "password",
"smtpUseTls": true
}
```
### Sync Mailbox
```http
POST /v1/mailboxes/{mailboxId}/sync
```
### List Mail Messages
```http
GET /v1/mail/messages?workspaceSlug={slug}&mailboxId={mailboxId}
```
### List Outgoing Mails
```http
GET /v1/mail/outgoing?workspaceSlug={slug}&mailboxId={mailboxId}
```
### Create Task from Mail
```http
POST /v1/mail/messages/{messageId}/create-task
Content-Type: application/json
{
"boardGroupId": "group-uuid",
"title": "Task from email",
"dueAt": "2024-01-20T10:00:00Z",
"color": "#3b82f6"
}
```
### Send/Schedule Outgoing Mail
```http
POST /v1/mail/outgoing
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"mailboxId": "mailbox-uuid",
"to": [{"name": "Jane", "email": "jane@example.com"}],
"cc": [],
"bcc": [],
"subject": "Hello",
"textBody": "Email content",
"htmlBody": "<p>Email content</p>",
"scheduledFor": "2024-01-15T09:00:00Z"
}
```
---
## Activity Feed
### List Activity
```http
GET /v1/activity?workspaceSlug={slug}&limit=40&type=task&q=search
```
**Query Parameters:**
- `workspaceSlug` (required) - Workspace identifier
- `limit` - Number of entries (default: 40)
- `type` - Filter by type: `task`, `calendar`, `note`, `focus`, `mail`, `invite`, `system`
- `q` - Search query
---
## Members & Invites
### List Members
```http
GET /v1/members?workspaceSlug={slug}
```
### Update Member Role
```http
PATCH /v1/members/{memberId}
Content-Type: application/json
{
"role": "admin",
"status": "active"
}
```
**Roles:** `owner`, `admin`, `member`
**Status:** `active`, `inactive`
### List Invites
```http
GET /v1/invites?workspaceSlug={slug}
```
### Create Invite
```http
POST /v1/invites
Content-Type: application/json
{
"workspaceSlug": "my-workspace",
"email": "newuser@example.com",
"role": "member"
}
```
### Accept Invite (Requires Auth)
```http
POST /v1/invites/{token}/accept
Content-Type: application/json
{
"name": "New User"
}
```
### Revoke Invite
```http
POST /v1/invites/{token}/revoke
```
### Get Invite Details (Public - No Auth Required)
```http
GET /v1/invites/{token}
```
---
## Health & Metrics
### Health Check (Public)
```http
GET /v1/health
```
Returns service status and storage health.
**Response:**
```json
{
"ok": true,
"mode": "production",
"timestamp": "2024-01-15T10:00:00Z",
"storage": {
"provider": "s3",
"ok": true
}
}
```
### Metrics (Requires Auth Token)
```http
GET /v1/metrics
Authorization: Bearer {METRICS_AUTH_TOKEN}
```
### Prometheus Metrics
```http
GET /v1/metrics/prometheus
Authorization: Bearer {METRICS_AUTH_TOKEN}
```
---
## Common Operations
### Creating a Complete Task
```javascript
// 1. Get workspace slug
const workspacesRes = await fetch('/v1/workspaces', { credentials: 'include' });
const workspaces = await workspacesRes.json();
const workspaceSlug = workspaces.data[0].slug;
// 2. Get or create board group
const groupsRes = await fetch(`/v1/board-groups?workspaceSlug=${workspaceSlug}`, { credentials: 'include' });
const groups = await groupsRes.json();
const groupId = groups.data[0].id;
// 3. Create task
const taskRes = await fetch('/v1/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
workspaceSlug,
title: 'New Task',
boardGroupId: groupId,
status: 'todo'
})
});
const task = await taskRes.json();
```
### Planning a Day
```javascript
// Create calendar event linked to a task
const eventRes = await fetch('/v1/calendar/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
workspaceSlug,
title: 'Deep Work',
startsAt: '2024-01-15T09:00:00Z',
endsAt: '2024-01-15T11:00:00Z',
linkedTaskId: task.data.id
})
});
// Start a focus session
const sessionRes = await fetch('/v1/focus/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
workspaceSlug,
taskId: task.data.id,
mode: 'pomodoro',
durationSeconds: 1500
})
});
```
### Converting Email to Task
```javascript
// 1. Get mail messages
const messagesRes = await fetch(`/v1/mail/messages?workspaceSlug=${workspaceSlug}&mailboxId=${mailboxId}`, { credentials: 'include' });
const messages = await messagesRes.json();
// 2. Get board groups
const groupsRes = await fetch(`/v1/board-groups?workspaceSlug=${workspaceSlug}`, { credentials: 'include' });
const groups = await groupsRes.json();
// 3. Create task from message
const taskRes = await fetch(`/v1/mail/messages/${messages.data[0].id}/create-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
boardGroupId: groups.data[0].id,
title: 'Follow up on email'
})
});
```
---
## Error Handling
Always check for error responses:
```javascript
const response = await fetch('/v1/tasks', { ... });
const data = await response.json();
if (data.error) {
console.error(`Error [${data.error.code}]: ${data.error.message}`);
// Handle error appropriately
}
```
**Common Error Codes:**
- `UNAUTHORIZED` - Session invalid or expired (401)
- `FORBIDDEN` - No access to workspace (403)
- `NOT_FOUND` - Resource not found (404)
- `VALIDATION_ERROR` - Invalid request data (400)
- `CONFLICT` - Business rule conflict (409)
- `INTERNAL_ERROR` - Server error (500)
---
## Agent Guidelines
1. **Always verify authentication** before attempting operations - check session status
2. **Ask for workspace context** if not provided (workspace slug)
3. **Handle errors gracefully** and inform the user with clear messages
4. **Respect user authorization** - never attempt operations without proper credentials
5. **Use appropriate HTTP methods**: GET for reading, POST for creating, PATCH for updating, DELETE for removing
6. **Include Content-Type header** for all JSON requests
7. **Include credentials** in fetch requests for cookie-based auth
---
## Development Notes
- **Dev Mailbox:** In development, magic links are captured at `/api/dev-mailbox`
- **CORS:** Configure `CORS_ALLOW_ORIGINS` for production
- **File Uploads:** Max 20MB for attachments
- **Session Cookies:** Must be included in requests (`credentials: 'include'` in fetch)
---
## Quick Reference Table
| Resource | List | Create | Update | Delete |
|----------|------|--------|--------|--------|
| Workspaces | `GET /v1/workspaces` | - | - | - |
| Tasks | `GET /v1/tasks?workspaceSlug=X` | `POST /v1/tasks` | `PATCH /v1/tasks/{id}` | - |
| Board Groups | `GET /v1/board-groups?workspaceSlug=X` | `POST /v1/board-groups` | `PATCH /v1/board-groups/{id}` | - |
| Calendar Events | `GET /v1/calendar/events?workspaceSlug=X` | `POST /v1/calendar/events` | `PATCH /v1/calendar/events/{id}` | - |
| Notes | `GET /v1/notes?workspaceSlug=X` | `POST /v1/notes` | `PATCH /v1/notes/{id}` | - |
| Focus Sessions | `GET /v1/focus/sessions?workspaceSlug=X` | `POST /v1/focus/sessions` | `PATCH /v1/focus/sessions/{id}` | - |
| Labels | `GET /v1/labels?workspaceSlug=X` | `POST /v1/labels` | - | - |
| Members | `GET /v1/members?workspaceSlug=X` | - | `PATCH /v1/members/{id}` | - |
| Invites | `GET /v1/invites?workspaceSlug=X` | `POST /v1/invites` | - | `POST /v1/invites/{token}/revoke` |
| Mailboxes | `GET /v1/mailboxes?workspaceSlug=X` | `POST /v1/mailboxes` | - | - |
| Mail Messages | `GET /v1/mail/messages?workspaceSlug=X&mailboxId=Y` | - | - | - |
| Outgoing Mail | `GET /v1/mail/outgoing?workspaceSlug=X&mailboxId=Y` | `POST /v1/mail/outgoing` | - | - |
| Activity | `GET /v1/activity?workspaceSlug=X` | - | - | - |
| Health | `GET /v1/health` | - | - | - |
| Metrics | `GET /v1/metrics` | - | - | - |
---
## Data Models
### Task
```typescript
interface Task {
id: string;
workspaceSlug: string;
boardGroupId: string;
title: string;
description: string;
status: 'todo' | 'in-progress' | 'done';
color: string;
dueAt?: string;
scheduledStart?: string;
scheduledEnd?: string;
assigneeId?: string;
labelIds: string[];
attachments: Attachment[];
comments: Comment[];
createdAt: string;
updatedAt: string;
}
```
### CalendarEvent
```typescript
interface CalendarEvent {
id: string;
workspaceSlug: string;
title: string;
description: string;
startsAt: string;
endsAt: string;
color: string;
linkedTaskId?: string;
attachments: Attachment[];
}
```
### Note
```typescript
interface Note {
id: string;
workspaceSlug: string;
title: string;
content: string;
updatedAt: string;
}
```
### FocusSession
```typescript
interface FocusSession {
id: string;
workspaceSlug: string;
taskId?: string;
mode: string;
startedAt: string;
completedAt?: string;
pausedAt?: string;
pausedTotalSeconds: number;
durationSeconds: number;
}
```
### Member
```typescript
interface Member {
id: string;
workspaceSlug: string;
name: string;
email: string;
role: 'owner' | 'admin' | 'member';
status: 'active' | 'inactive';
}
```
### Attachment
```typescript
interface Attachment {
id: string;
name: string;
mimeType: string;
size: number;
dataURL: string;
}
```
### MailAddress
```typescript
interface MailAddress {
name: string;
email: string;
}
```
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags='-s -w' -o /out/api ./cmd/api
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S app \
&& adduser -S -G app app
WORKDIR /app
COPY --from=build /out/api /app/api
COPY internal/db/migrations /app/migrations
ENV APP_ENV=production
ENV API_PORT=8080
ENV DB_MIGRATIONS_DIR=/app/migrations
EXPOSE 8080
USER app
ENTRYPOINT ["/app/api"]
BIN
View File
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS runtime
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-fund --no-audit
COPY src ./src
ENV NODE_ENV=production
ENV AUTH_PORT=3001
EXPOSE 3001
USER node
CMD ["node", "src/server.mjs"]
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@productier/auth-service",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/server.mjs",
"start": "node src/server.mjs",
"check": "node --check src/server.mjs && node --check src/auth.mjs"
},
"dependencies": {
"better-auth": "^1.5.6",
"kysely": "^0.28.14",
"nodemailer": "^6.10.1",
"pg": "^8.20.0"
}
}
+311
View File
@@ -0,0 +1,311 @@
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
import { Kysely, PostgresDialect } from "kysely";
import nodemailer from "nodemailer";
import { Pool } from "pg";
import { recordDevMail } from "./dev-mailbox.mjs";
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const authUrl = process.env.AUTH_URL || "http://localhost:3001";
const databaseUrl = process.env.DATABASE_URL || "postgres://productier:productier@localhost:5432/productier?sslmode=disable";
const authSecret = process.env.BETTER_AUTH_SECRET || "replace-me-with-a-long-random-secret";
const appMode = (process.env.APP_ENV || process.env.NODE_ENV || "development").trim().toLowerCase();
const productionLikeMode = appMode === "staging" || appMode === "production";
const magicLinkProvider = resolveMagicLinkProvider(process.env.AUTH_MAGIC_LINK_PROVIDER, productionLikeMode);
export const devMailboxEnabled = resolveDevMailboxEnabled(process.env.AUTH_DEV_MAILBOX_ENABLED, appMode);
const smtpConfig = magicLinkProvider === "smtp" ? loadSMTPConfig() : null;
validateRuntimeConfig();
const magicLinkTransport = createMagicLinkTransport();
const pool = new Pool({
connectionString: databaseUrl
});
const db = new Kysely({
dialect: new PostgresDialect({
pool
})
});
export const auth = betterAuth({
appName: "Productier",
baseURL: authUrl,
secret: authSecret,
trustedOrigins: [frontendUrl, authUrl],
database: {
db,
type: "postgres"
},
emailAndPassword: {
enabled: true,
autoSignIn: true
},
user: {
changeEmail: {
enabled: false
}
},
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await magicLinkTransport.send({
email,
url
});
}
})
]
});
let authReadyPromise = null;
export async function ensureAuthReady() {
if (!authReadyPromise) {
authReadyPromise = auth.$context
.then(async context => {
await context.runMigrations();
await magicLinkTransport.verify();
})
.catch(error => {
authReadyPromise = null;
throw error;
});
}
return authReadyPromise;
}
export async function closeAuth() {
await magicLinkTransport.close();
await pool.end();
}
function validateRuntimeConfig() {
assertHTTPURL(frontendUrl, "FRONTEND_URL");
assertHTTPURL(authUrl, "AUTH_URL");
assertAbsoluteURL(databaseUrl, "DATABASE_URL");
if (!productionLikeMode) {
return;
}
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL must be explicitly set in staging/production");
}
if (isWeakSecret(authSecret)) {
throw new Error("BETTER_AUTH_SECRET must be set to a strong non-placeholder value in staging/production");
}
assertSecurePublicURL(frontendUrl, "FRONTEND_URL");
assertSecurePublicURL(authUrl, "AUTH_URL");
if (magicLinkProvider !== "smtp") {
throw new Error("AUTH_MAGIC_LINK_PROVIDER must be set to smtp in staging/production");
}
if (devMailboxEnabled) {
throw new Error("AUTH_DEV_MAILBOX_ENABLED must be false in staging/production");
}
}
function assertHTTPURL(value, envName) {
let parsed;
try {
parsed = new URL(value);
} catch (error) {
throw new Error(`${envName} must be a valid absolute URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${envName} must use http or https`);
}
}
function assertAbsoluteURL(value, envName) {
try {
// eslint-disable-next-line no-new
new URL(value);
} catch (error) {
throw new Error(`${envName} must be a valid absolute URL`);
}
}
function assertSecurePublicURL(value, envName) {
const parsed = new URL(value);
const hostname = parsed.hostname.toLowerCase();
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return;
}
if (parsed.protocol !== "https:") {
throw new Error(`${envName} must use https in staging/production`);
}
}
function isWeakSecret(secret) {
const normalized = secret.trim().toLowerCase();
if (
normalized === "" ||
normalized === "replace-me-with-a-long-random-secret" ||
normalized === "changeme" ||
normalized === "change-me" ||
normalized === "replace-me"
) {
return true;
}
return secret.trim().length < 16;
}
function resolveMagicLinkProvider(rawValue, isProductionLike) {
const normalized = String(rawValue || "")
.trim()
.toLowerCase();
if (!normalized) {
return isProductionLike ? "smtp" : "dev-mailbox";
}
if (normalized !== "dev-mailbox" && normalized !== "smtp") {
throw new Error("AUTH_MAGIC_LINK_PROVIDER must be one of: dev-mailbox, smtp");
}
return normalized;
}
function resolveDevMailboxEnabled(rawValue, mode) {
const defaultEnabled = mode === "development" || mode === "test";
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
return defaultEnabled;
}
return parseBoolean(rawValue, "AUTH_DEV_MAILBOX_ENABLED");
}
function parseBoolean(rawValue, envName) {
const normalized = String(rawValue).trim().toLowerCase();
switch (normalized) {
case "1":
case "true":
case "yes":
case "on":
return true;
case "0":
case "false":
case "no":
case "off":
return false;
default:
throw new Error(`${envName} must be a boolean value`);
}
}
function loadSMTPConfig() {
const host = String(process.env.AUTH_SMTP_HOST || "").trim();
const username = String(process.env.AUTH_SMTP_USER || "").trim();
const password = String(process.env.AUTH_SMTP_PASSWORD || "");
const from = String(process.env.AUTH_MAIL_FROM || "").trim();
if (!host) {
throw new Error("AUTH_SMTP_HOST is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
if (!username) {
throw new Error("AUTH_SMTP_USER is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
if (!password) {
throw new Error("AUTH_SMTP_PASSWORD is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
if (!from) {
throw new Error("AUTH_MAIL_FROM is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
const portRaw = String(process.env.AUTH_SMTP_PORT || "587").trim();
const port = Number.parseInt(portRaw, 10);
if (Number.isNaN(port) || port < 1 || port > 65535) {
throw new Error("AUTH_SMTP_PORT must be a valid TCP port");
}
const secure = process.env.AUTH_SMTP_SECURE
? parseBoolean(process.env.AUTH_SMTP_SECURE, "AUTH_SMTP_SECURE")
: port === 465;
const skipVerify = process.env.AUTH_SMTP_SKIP_VERIFY
? parseBoolean(process.env.AUTH_SMTP_SKIP_VERIFY, "AUTH_SMTP_SKIP_VERIFY")
: false;
const tlsRejectUnauthorized = process.env.AUTH_SMTP_TLS_REJECT_UNAUTHORIZED
? parseBoolean(process.env.AUTH_SMTP_TLS_REJECT_UNAUTHORIZED, "AUTH_SMTP_TLS_REJECT_UNAUTHORIZED")
: true;
return {
host,
port,
secure,
username,
password,
from,
skipVerify,
tlsRejectUnauthorized
};
}
function createMagicLinkTransport() {
if (magicLinkProvider === "dev-mailbox") {
return {
async send({ email, url }) {
await recordDevMail({
kind: "magic-link",
email,
subject: "Your Productier magic link",
url
});
console.log(`[Productier auth] magic link for ${email}: ${url}`);
},
async verify() {
return;
},
async close() {
return;
}
};
}
const transporter = nodemailer.createTransport({
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.secure,
auth: {
user: smtpConfig.username,
pass: smtpConfig.password
},
tls: {
rejectUnauthorized: smtpConfig.tlsRejectUnauthorized
}
});
return {
async send({ email, url }) {
const subject = "Your Productier magic link";
const text = [
"Sign in to Productier using this secure magic link:",
url,
"",
"If you did not request this link, you can ignore this email."
].join("\n");
const html = [
"<p>Sign in to Productier using this secure magic link:</p>",
`<p><a href="${url}">${url}</a></p>`,
"<p>If you did not request this link, you can ignore this email.</p>"
].join("");
await transporter.sendMail({
from: smtpConfig.from,
to: email,
subject,
text,
html
});
},
async verify() {
if (smtpConfig.skipVerify) {
return;
}
await transporter.verify();
},
async close() {
if (typeof transporter.close === "function") {
transporter.close();
}
}
};
}
@@ -0,0 +1,35 @@
import { promises as fs } from "node:fs";
import path from "node:path";
const MAILBOX_PATH = path.join(process.cwd(), ".productier-dev-mailbox.json");
async function readMailbox() {
try {
const raw = await fs.readFile(MAILBOX_PATH, "utf8");
const entries = JSON.parse(raw);
return Array.isArray(entries) ? entries : [];
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
export async function recordDevMail(entry) {
const mailbox = await readMailbox();
mailbox.unshift({
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...entry
});
await fs.writeFile(MAILBOX_PATH, JSON.stringify(mailbox.slice(0, 25), null, 2));
}
export async function listDevMail(email) {
const mailbox = await readMailbox();
if (!email) {
return mailbox;
}
return mailbox.filter(entry => entry.email.toLowerCase() === email.toLowerCase());
}
+152
View File
@@ -0,0 +1,152 @@
import http from "node:http";
import { URL } from "node:url";
import { toNodeHandler } from "better-auth/node";
import { auth, closeAuth, devMailboxEnabled, ensureAuthReady } from "./auth.mjs";
import { listDevMail } from "./dev-mailbox.mjs";
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const authPort = Number(process.env.AUTH_PORT || "3001");
const appMode = (process.env.APP_ENV || process.env.NODE_ENV || "development").trim().toLowerCase();
const enforceExplicitCORS = appMode === "staging" || appMode === "production";
const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOW_ORIGINS, frontendUrl, enforceExplicitCORS);
const authHandler = toNodeHandler(auth);
let isShuttingDown = false;
let shutdownTimer = null;
function setCorsHeaders(request, response) {
const requestOrigin = request.headers.origin;
if (requestOrigin && allowedOrigins.has(requestOrigin)) {
response.setHeader("Access-Control-Allow-Origin", requestOrigin);
} else if (!requestOrigin) {
response.setHeader("Access-Control-Allow-Origin", frontendUrl);
}
response.setHeader("Vary", "Origin");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
}
const server = http.createServer(async (request, response) => {
setCorsHeaders(request, response);
if (request.method === "OPTIONS") {
response.writeHead(204);
response.end();
return;
}
const url = new URL(request.url || "/", `http://${request.headers.host || `localhost:${authPort}`}`);
try {
if (url.pathname === "/api/dev-mailbox" && request.method === "GET" && devMailboxEnabled) {
const email = url.searchParams.get("email") || undefined;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ data: await listDevMail(email) }));
return;
}
if (url.pathname.startsWith("/api/auth/")) {
authHandler(request, response);
return;
}
if (url.pathname === "/health" && request.method === "GET") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: true, service: "auth" }));
return;
}
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Not found" }));
} catch (error) {
console.error("[Productier auth] request failed", error);
response.writeHead(500, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Internal server error" }));
}
});
async function bootstrap() {
try {
await ensureAuthReady();
} catch (error) {
console.error("[Productier auth] startup failed during auth initialization", error);
process.exit(1);
}
server.listen(authPort, () => {
console.log(`[Productier auth] listening on http://localhost:${authPort}`);
});
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.log(`[Productier auth] received ${signal}, shutting down`);
shutdownTimer = setTimeout(() => {
console.error("[Productier auth] forced shutdown after timeout");
process.exit(1);
}, 10000);
shutdownTimer.unref();
server.close(async closeError => {
if (closeError) {
console.error("[Productier auth] server close failed", closeError);
process.exitCode = 1;
}
try {
await closeAuth();
} catch (error) {
console.error("[Productier auth] auth shutdown failed", error);
process.exitCode = 1;
} finally {
if (shutdownTimer) {
clearTimeout(shutdownTimer);
}
process.exit();
}
});
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
void bootstrap();
function parseAllowedOrigins(raw, fallbackOrigin, strictMode) {
const origins = new Set();
if (strictMode && (!raw || raw.trim().length === 0)) {
throw new Error("CORS_ALLOW_ORIGINS must be set in staging/production");
}
const source = raw && raw.trim().length > 0 ? raw : fallbackOrigin;
source
.split(",")
.map(value => value.trim())
.filter(Boolean)
.forEach(origin => {
try {
origins.add(new URL(origin).origin);
} catch (error) {
if (strictMode) {
throw new Error(`invalid origin in CORS_ALLOW_ORIGINS: ${origin}`);
}
console.warn(`[Productier auth] ignoring invalid CORS origin: ${origin}`);
}
});
if (origins.size === 0) {
if (strictMode) {
throw new Error("CORS_ALLOW_ORIGINS must include at least one valid origin");
}
origins.add(fallbackOrigin);
}
return origins;
}
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"context"
"os"
"os/signal"
"syscall"
"go.uber.org/zap"
"productier/apps/backend/internal/app"
)
func main() {
logger, err := zap.NewProduction()
if err != nil {
panic(err)
}
defer logger.Sync()
server, err := app.New(logger)
if err != nil {
logger.Fatal("create api app", zap.Error(err))
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := server.RunContext(ctx); err != nil {
logger.Fatal("run api server", zap.Error(err))
}
}
+60
View File
@@ -0,0 +1,60 @@
# Productier Backend Remote Deployment
# Usage: docker compose -f docker-compose.remote.yml --env-file remote.env up -d
#
# Prerequisites:
# 1. Copy remote.env to .env and configure all required values
# 2. Ensure PostgreSQL database is accessible
# 3. Ensure auth service is running and accessible at AUTH_SERVICE_URL
services:
api:
build:
context: .
dockerfile: Dockerfile
image: productier-api:latest
restart: unless-stopped
init: true
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
ports:
- "${API_PORT:-8080}:8080"
environment:
APP_ENV: ${APP_ENV:-production}
API_PORT: 8080
API_SHUTDOWN_TIMEOUT: ${API_SHUTDOWN_TIMEOUT:-15s}
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
AUTH_SERVICE_URL: ${AUTH_SERVICE_URL:?AUTH_SERVICE_URL is required}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:?CORS_ALLOW_ORIGINS is required}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET is required}
MAIL_ENCRYPTION_KEY: ${MAIL_ENCRYPTION_KEY:?MAIL_ENCRYPTION_KEY is required}
FILE_STORAGE_PROVIDER: ${FILE_STORAGE_PROVIDER:-local}
FILE_STORAGE_DIR: ${FILE_STORAGE_DIR:-/tmp/uploads}
DB_MIGRATIONS_DIR: /app/migrations
# S3 storage (if FILE_STORAGE_PROVIDER=s3)
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-productier}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
S3_USE_PATH_STYLE: ${S3_USE_PATH_STYLE:-false}
# Optional metrics auth
METRICS_AUTH_TOKEN: ${METRICS_AUTH_TOKEN:-}
volumes:
# Persist uploads if using local storage
- uploads-data:${FILE_STORAGE_DIR:-/tmp/uploads}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/v1/health"]
interval: 15s
timeout: 5s
retries: 20
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes:
uploads-data:
+67
View File
@@ -0,0 +1,67 @@
module productier/apps/backend
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.13
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.24.2
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-message v0.18.2
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/pressly/goose/v3 v3.27.0
go.uber.org/zap v1.27.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.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/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+194
View File
@@ -0,0 +1,194 @@
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/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.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"go.uber.org/zap"
"productier/apps/backend/internal/authsession"
"productier/apps/backend/internal/filestorage"
"productier/apps/backend/internal/httpapi"
"productier/apps/backend/internal/mailruntime"
"productier/apps/backend/internal/store"
)
type App struct {
server *httpapi.Server
port string
shutdownTimeout time.Duration
stopMailRuntime context.CancelFunc
}
func New(logger *zap.Logger) (*App, error) {
runtimeConfig, err := loadRuntimeConfig()
if err != nil {
return nil, err
}
var dataStore store.Store
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL != "" {
persistentStore, err := store.NewPostgresStore(databaseURL, runtimeConfig.mode)
if err != nil {
return nil, err
}
dataStore = persistentStore
} else {
if err := validateStoreRuntimeMode(runtimeConfig.mode, inMemoryStoreAllowed()); err != nil {
return nil, err
}
dataStore = store.NewSeededState(runtimeConfig.mode)
}
mailService, err := mailruntime.New(dataStore, logger, runtimeConfig.mailSecret)
if err != nil {
return nil, err
}
files, err := filestorage.NewFromEnv()
if err != nil {
return nil, err
}
probeCtx, cancelProbe := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelProbe()
if err := files.Probe(probeCtx); err != nil {
return nil, fmt.Errorf("file storage startup probe failed: %w", err)
}
mailRuntimeCtx, stopMailRuntime := context.WithCancel(context.Background())
mailService.Start(mailRuntimeCtx)
return &App{
server: httpapi.NewServer(
dataStore,
authsession.NewClient(runtimeConfig.authServiceURL),
mailService,
files,
runtimeConfig.mode,
runtimeConfig.corsAllowOrigins,
runtimeConfig.metricsAuthToken,
logger,
),
port: runtimeConfig.apiPort,
shutdownTimeout: runtimeConfig.shutdownTimeout,
stopMailRuntime: stopMailRuntime,
}, nil
}
func (a *App) Run() error {
return a.RunContext(context.Background())
}
func (a *App) RunContext(ctx context.Context) error {
httpServer := &http.Server{
Addr: fmt.Sprintf(":%s", a.port),
Handler: a.server.Engine(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
close(serverErr)
}()
select {
case <-ctx.Done():
if a.stopMailRuntime != nil {
a.stopMailRuntime()
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown api server: %w", err)
}
if err, ok := <-serverErr; ok && err != nil {
return fmt.Errorf("run api server: %w", err)
}
return nil
case err, ok := <-serverErr:
if a.stopMailRuntime != nil {
a.stopMailRuntime()
}
if !ok || err == nil {
return nil
}
return fmt.Errorf("run api server: %w", err)
}
}
func validateStoreRuntimeMode(mode string, allowInMemory bool) error {
if mode == "development" || allowInMemory {
return nil
}
return fmt.Errorf("DATABASE_URL is required when APP_ENV=%q (set ALLOW_INMEMORY_STORE=true only for temporary non-production testing)", mode)
}
func inMemoryStoreAllowed() bool {
raw := strings.TrimSpace(strings.ToLower(os.Getenv("ALLOW_INMEMORY_STORE")))
switch raw {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+72
View File
@@ -0,0 +1,72 @@
package app
import "testing"
func TestValidateStoreRuntimeMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mode string
allowInMemory bool
expectError bool
}{
{
name: "development allows in-memory store",
mode: "development",
allowInMemory: false,
expectError: false,
},
{
name: "production rejects in-memory store by default",
mode: "production",
allowInMemory: false,
expectError: true,
},
{
name: "non-development can be explicitly overridden",
mode: "staging",
allowInMemory: true,
expectError: false,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
err := validateStoreRuntimeMode(test.mode, test.allowInMemory)
if test.expectError && err == nil {
t.Fatalf("expected error for mode=%q allowInMemory=%v", test.mode, test.allowInMemory)
}
if !test.expectError && err != nil {
t.Fatalf("did not expect error for mode=%q allowInMemory=%v: %v", test.mode, test.allowInMemory, err)
}
})
}
}
func TestInMemoryStoreAllowed(t *testing.T) {
tests := []struct {
name string
value string
allowed bool
}{
{name: "empty", value: "", allowed: false},
{name: "true", value: "true", allowed: true},
{name: "uppercase true", value: "TRUE", allowed: true},
{name: "one", value: "1", allowed: true},
{name: "yes", value: "yes", allowed: true},
{name: "off", value: "off", allowed: false},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Setenv("ALLOW_INMEMORY_STORE", test.value)
if got := inMemoryStoreAllowed(); got != test.allowed {
t.Fatalf("inMemoryStoreAllowed() = %v, want %v for %q", got, test.allowed, test.value)
}
})
}
}
+256
View File
@@ -0,0 +1,256 @@
package app
import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const (
defaultAppMode = "development"
defaultAPIPort = "8080"
defaultShutdownTimeout = 10 * time.Second
)
var (
defaultLocalCORSOrigins = []string{
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
}
insecureSecretPlaceholders = map[string]struct{}{
"": {},
"replace-me-with-a-long-random-secret": {},
"replace-me-with-a-dedicated-mail-secret": {},
"productier-local-mail-key": {},
"changeme": {},
"change-me": {},
"replace-me": {},
}
)
type runtimeConfig struct {
mode string
apiPort string
authServiceURL string
shutdownTimeout time.Duration
corsAllowOrigins []string
mailSecret string
metricsAuthToken string
}
func loadRuntimeConfig() (runtimeConfig, error) {
mode, err := parseAppMode(os.Getenv("APP_ENV"))
if err != nil {
return runtimeConfig{}, err
}
apiPort, err := parsePort(os.Getenv("API_PORT"), defaultAPIPort, "API_PORT")
if err != nil {
return runtimeConfig{}, err
}
authServiceURL, err := parseAbsoluteHTTPURL(
valueOrDefault(strings.TrimSpace(os.Getenv("AUTH_SERVICE_URL")), "http://localhost:3001"),
"AUTH_SERVICE_URL",
)
if err != nil {
return runtimeConfig{}, err
}
shutdownTimeout, err := parseDuration(
os.Getenv("API_SHUTDOWN_TIMEOUT"),
defaultShutdownTimeout,
"API_SHUTDOWN_TIMEOUT",
)
if err != nil {
return runtimeConfig{}, err
}
corsAllowOrigins, err := parseCORSAllowOrigins(mode, os.Getenv("CORS_ALLOW_ORIGINS"))
if err != nil {
return runtimeConfig{}, err
}
mailSecret, err := resolveMailSecret(mode)
if err != nil {
return runtimeConfig{}, err
}
metricsAuthToken, err := resolveMetricsAuthToken(mode)
if err != nil {
return runtimeConfig{}, err
}
return runtimeConfig{
mode: mode,
apiPort: apiPort,
authServiceURL: authServiceURL,
shutdownTimeout: shutdownTimeout,
corsAllowOrigins: corsAllowOrigins,
mailSecret: mailSecret,
metricsAuthToken: metricsAuthToken,
}, nil
}
func parseAppMode(raw string) (string, error) {
mode := strings.TrimSpace(strings.ToLower(raw))
if mode == "" {
return defaultAppMode, nil
}
switch mode {
case "development", "test", "staging", "production":
return mode, nil
default:
return "", fmt.Errorf("unsupported APP_ENV %q (allowed: development, test, staging, production)", mode)
}
}
func parsePort(raw string, fallback string, envName string) (string, error) {
port := strings.TrimSpace(raw)
if port == "" {
port = fallback
}
numeric, err := strconv.Atoi(port)
if err != nil || numeric < 1 || numeric > 65535 {
return "", fmt.Errorf("%s must be a valid TCP port (1-65535)", envName)
}
return strconv.Itoa(numeric), nil
}
func parseDuration(raw string, fallback time.Duration, envName string) (time.Duration, error) {
value := strings.TrimSpace(raw)
if value == "" {
return fallback, nil
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a valid duration (example: 10s): %w", envName, err)
}
if duration <= 0 {
return 0, fmt.Errorf("%s must be greater than zero", envName)
}
return duration, nil
}
func parseAbsoluteHTTPURL(raw string, envName string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", fmt.Errorf("%s is required", envName)
}
parsed, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("%s must be a valid URL: %w", envName, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("%s must use http or https", envName)
}
if parsed.Host == "" {
return "", fmt.Errorf("%s must include a host", envName)
}
return strings.TrimRight(parsed.String(), "/"), nil
}
func parseCORSAllowOrigins(mode string, raw string) ([]string, error) {
value := strings.TrimSpace(raw)
if value == "" {
if mode == "staging" || mode == "production" {
return nil, errors.New("CORS_ALLOW_ORIGINS is required in staging/production (comma-separated origins)")
}
return append([]string(nil), defaultLocalCORSOrigins...), nil
}
parts := strings.Split(value, ",")
origins := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
origin := strings.TrimSpace(part)
if origin == "" {
continue
}
if origin == "*" {
return nil, errors.New("CORS_ALLOW_ORIGINS cannot include '*' when credentials are enabled")
}
validated, err := parseOrigin(origin, "CORS_ALLOW_ORIGINS")
if err != nil {
return nil, err
}
if _, exists := seen[validated]; exists {
continue
}
seen[validated] = struct{}{}
origins = append(origins, validated)
}
if len(origins) == 0 {
return nil, errors.New("CORS_ALLOW_ORIGINS must include at least one valid origin")
}
return origins, nil
}
func parseOrigin(raw string, envName string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("%s must contain valid origins: %w", envName, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("%s origins must use http or https", envName)
}
if parsed.Host == "" {
return "", fmt.Errorf("%s origins must include a host", envName)
}
if parsed.Path != "" && parsed.Path != "/" {
return "", fmt.Errorf("%s origins cannot include URL paths", envName)
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("%s origins cannot include query or fragment components", envName)
}
return parsed.Scheme + "://" + parsed.Host, nil
}
func resolveMailSecret(mode string) (string, error) {
mailSecret := strings.TrimSpace(os.Getenv("MAIL_ENCRYPTION_KEY"))
if mailSecret == "" {
mailSecret = strings.TrimSpace(os.Getenv("BETTER_AUTH_SECRET"))
}
if mode != "staging" && mode != "production" {
return mailSecret, nil
}
if isInsecureSecret(mailSecret) {
return "", errors.New("set a strong MAIL_ENCRYPTION_KEY (or BETTER_AUTH_SECRET fallback) for staging/production")
}
return mailSecret, nil
}
func resolveMetricsAuthToken(mode string) (string, error) {
token := strings.TrimSpace(os.Getenv("METRICS_AUTH_TOKEN"))
if token == "" {
return "", nil
}
if mode == "production" && isInsecureSecret(token) {
return "", errors.New("METRICS_AUTH_TOKEN must be a strong non-placeholder secret when set in production")
}
return token, nil
}
func isInsecureSecret(secret string) bool {
normalized := strings.TrimSpace(strings.ToLower(secret))
if _, exists := insecureSecretPlaceholders[normalized]; exists {
return true
}
return len(strings.TrimSpace(secret)) < 16
}
func valueOrDefault(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
+146
View File
@@ -0,0 +1,146 @@
package app
import (
"testing"
"time"
)
func TestParseAppMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
expectErr bool
}{
{name: "default", value: "", want: "development"},
{name: "production", value: "production", want: "production"},
{name: "normalized", value: " StAgInG ", want: "staging"},
{name: "invalid", value: "prod", expectErr: true},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got, err := parseAppMode(test.value)
if test.expectErr {
if err == nil {
t.Fatalf("expected error for %q", test.value)
}
return
}
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if got != test.want {
t.Fatalf("parseAppMode(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestParseDuration(t *testing.T) {
t.Parallel()
got, err := parseDuration("", 10*time.Second, "API_SHUTDOWN_TIMEOUT")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if got != 10*time.Second {
t.Fatalf("parseDuration default = %s, want 10s", got)
}
got, err = parseDuration("15s", 10*time.Second, "API_SHUTDOWN_TIMEOUT")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if got != 15*time.Second {
t.Fatalf("parseDuration explicit = %s, want 15s", got)
}
if _, err := parseDuration("0s", 10*time.Second, "API_SHUTDOWN_TIMEOUT"); err == nil {
t.Fatal("expected zero duration to fail")
}
if _, err := parseDuration("nope", 10*time.Second, "API_SHUTDOWN_TIMEOUT"); err == nil {
t.Fatal("expected invalid duration to fail")
}
}
func TestParseCORSAllowOrigins(t *testing.T) {
t.Parallel()
devOrigins, err := parseCORSAllowOrigins("development", "")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if len(devOrigins) == 0 {
t.Fatal("expected default development origins")
}
if _, err := parseCORSAllowOrigins("production", ""); err == nil {
t.Fatal("expected production with empty CORS_ALLOW_ORIGINS to fail")
}
origins, err := parseCORSAllowOrigins("production", "https://app.example.com, https://app.example.com ,https://admin.example.com")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if len(origins) != 2 {
t.Fatalf("expected 2 deduplicated origins, got %d", len(origins))
}
if _, err := parseCORSAllowOrigins("production", "*"); err == nil {
t.Fatal("expected wildcard origin to fail")
}
if _, err := parseCORSAllowOrigins("production", "https://app.example.com/path"); err == nil {
t.Fatal("expected origin with path to fail")
}
}
func TestIsInsecureSecret(t *testing.T) {
t.Parallel()
if !isInsecureSecret("replace-me-with-a-long-random-secret") {
t.Fatal("expected placeholder secret to be insecure")
}
if !isInsecureSecret("short") {
t.Fatal("expected short secret to be insecure")
}
if isInsecureSecret("this-is-a-strong-enough-secret-12345") {
t.Fatal("expected long random secret to pass")
}
}
func TestResolveMetricsAuthToken(t *testing.T) {
t.Run("empty token is allowed", func(t *testing.T) {
t.Setenv("METRICS_AUTH_TOKEN", "")
token, err := resolveMetricsAuthToken("production")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if token != "" {
t.Fatalf("token = %q, want empty", token)
}
})
t.Run("rejects weak token in production", func(t *testing.T) {
t.Setenv("METRICS_AUTH_TOKEN", "short")
if _, err := resolveMetricsAuthToken("production"); err == nil {
t.Fatal("expected weak production token to fail")
}
})
t.Run("allows token in production", func(t *testing.T) {
t.Setenv("METRICS_AUTH_TOKEN", "this-is-a-strong-enough-secret-98765")
token, err := resolveMetricsAuthToken("production")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if token == "" {
t.Fatal("expected resolved token")
}
})
}
@@ -0,0 +1,66 @@
package authsession
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type sessionEnvelope struct {
User *User `json:"user"`
}
type Client struct {
baseURL string
httpClient *http.Client
}
func NewClient(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
}
}
func (c *Client) GetUser(ctx context.Context, cookieHeader string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/auth/get-session", nil)
if err != nil {
return nil, fmt.Errorf("create auth session request: %w", err)
}
if cookieHeader != "" {
req.Header.Set("Cookie", cookieHeader)
}
res, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request auth session: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("auth session status: %s", res.Status)
}
var payload *sessionEnvelope
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decode auth session: %w", err)
}
if payload == nil || payload.User == nil {
return nil, nil
}
return payload.User, nil
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
@@ -0,0 +1,174 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
import (
"database/sql"
"encoding/json"
"time"
)
type ActivityEntry struct {
ID string
WorkspaceSlug string
Title string
Detail string
CreatedAt time.Time
}
type BoardGroup struct {
ID string
WorkspaceSlug string
Name string
Color string
SortOrder int32
}
type CalendarEvent struct {
ID string
WorkspaceSlug string
Title string
Description string
StartsAt time.Time
EndsAt time.Time
Color string
LinkedTaskID sql.NullString
Attachments json.RawMessage
}
type FocusSession struct {
ID string
WorkspaceSlug string
TaskID sql.NullString
Mode string
StartedAt time.Time
CompletedAt sql.NullTime
PausedAt sql.NullTime
PausedTotalSeconds int32
DurationSeconds int32
}
type Invite struct {
ID string
WorkspaceSlug string
Email string
Role string
Token string
CreatedAt time.Time
Status string
}
type Label struct {
ID string
WorkspaceSlug string
Name string
Color string
}
type MailMessage struct {
ID string
WorkspaceSlug string
MailboxID string
RemoteUid int64
MessageID string
Folder string
FromAddress json.RawMessage
ToRecipients json.RawMessage
CcRecipients json.RawMessage
Subject string
Snippet string
TextBody string
HtmlBody string
ReceivedAt time.Time
IsRead bool
LinkedTaskID sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
}
type Mailbox struct {
ID string
WorkspaceSlug string
Label string
Email string
DisplayName string
ImapHost string
ImapPort int32
ImapUsername string
ImapPasswordCiphertext string
ImapUseTls bool
SmtpHost string
SmtpPort int32
SmtpUsername string
SmtpPasswordCiphertext string
SmtpUseTls bool
SyncStatus string
SyncError string
LastSyncedAt sql.NullTime
CreatedAt time.Time
UpdatedAt time.Time
}
type Member struct {
ID string
WorkspaceSlug string
Name string
Email string
Role string
Status string
}
type Note struct {
ID string
WorkspaceSlug string
Title string
Content string
UpdatedAt time.Time
}
type OutgoingMail struct {
ID string
WorkspaceSlug string
MailboxID string
ToRecipients json.RawMessage
CcRecipients json.RawMessage
BccRecipients json.RawMessage
Subject string
TextBody string
HtmlBody string
Status string
ScheduledFor sql.NullTime
SentAt sql.NullTime
ErrorMessage string
CreatedAt time.Time
UpdatedAt time.Time
}
type Task struct {
ID string
WorkspaceSlug string
BoardGroupID string
Title string
Description string
Status string
Color string
DueAt sql.NullTime
ScheduledStart sql.NullTime
ScheduledEnd sql.NullTime
AssigneeID sql.NullString
LabelIds json.RawMessage
Attachments json.RawMessage
Comments json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type Workspace struct {
ID string
Slug string
Name string
Role string
CreatedAt time.Time
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS workspaces (
id text PRIMARY KEY,
slug text NOT NULL UNIQUE,
name text NOT NULL,
role text NOT NULL,
created_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS members (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
email text NOT NULL,
role text NOT NULL,
status text NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS members_workspace_email_idx ON members (workspace_slug, email);
CREATE TABLE IF NOT EXISTS invites (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
email text NOT NULL,
role text NOT NULL,
token text NOT NULL UNIQUE,
created_at timestamptz NOT NULL,
status text NOT NULL
);
CREATE TABLE IF NOT EXISTS activity_entries (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
title text NOT NULL,
detail text NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_entries_workspace_created_idx ON activity_entries (workspace_slug, created_at DESC);
CREATE TABLE IF NOT EXISTS board_groups (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
color text NOT NULL,
sort_order integer NOT NULL
);
CREATE TABLE IF NOT EXISTS labels (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
color text NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
board_group_id text NOT NULL REFERENCES board_groups(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
status text NOT NULL,
color text NOT NULL,
due_at timestamptz,
scheduled_start timestamptz,
scheduled_end timestamptz,
assignee_id text,
label_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
attachments jsonb NOT NULL DEFAULT '[]'::jsonb,
comments jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS tasks_workspace_updated_idx ON tasks (workspace_slug, updated_at DESC);
CREATE TABLE IF NOT EXISTS calendar_events (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
color text NOT NULL,
linked_task_id text,
attachments jsonb NOT NULL DEFAULT '[]'::jsonb
);
CREATE INDEX IF NOT EXISTS calendar_events_workspace_starts_idx ON calendar_events (workspace_slug, starts_at ASC);
CREATE TABLE IF NOT EXISTS notes (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
title text NOT NULL,
content text NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS focus_sessions (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
task_id text,
mode text NOT NULL,
started_at timestamptz NOT NULL,
completed_at timestamptz,
paused_at timestamptz,
paused_total_seconds integer NOT NULL DEFAULT 0,
duration_seconds integer NOT NULL
);
CREATE INDEX IF NOT EXISTS focus_sessions_workspace_started_idx ON focus_sessions (workspace_slug, started_at DESC);
-- +goose Down
DROP TABLE IF EXISTS focus_sessions;
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS calendar_events;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS labels;
DROP TABLE IF EXISTS board_groups;
DROP TABLE IF EXISTS activity_entries;
DROP TABLE IF EXISTS invites;
DROP INDEX IF EXISTS members_workspace_email_idx;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS workspaces;
@@ -0,0 +1,75 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS mailboxes (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
label text NOT NULL,
email text NOT NULL,
display_name text NOT NULL DEFAULT '',
imap_host text NOT NULL,
imap_port integer NOT NULL,
imap_username text NOT NULL,
imap_password_ciphertext text NOT NULL,
imap_use_tls boolean NOT NULL DEFAULT true,
smtp_host text NOT NULL,
smtp_port integer NOT NULL,
smtp_username text NOT NULL,
smtp_password_ciphertext text NOT NULL,
smtp_use_tls boolean NOT NULL DEFAULT true,
sync_status text NOT NULL DEFAULT 'idle',
sync_error text NOT NULL DEFAULT '',
last_synced_at timestamptz,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS mailboxes_workspace_updated_idx ON mailboxes (workspace_slug, updated_at DESC);
CREATE TABLE IF NOT EXISTS mail_messages (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
mailbox_id text NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
remote_uid bigint NOT NULL,
message_id text NOT NULL DEFAULT '',
folder text NOT NULL DEFAULT 'INBOX',
from_address jsonb NOT NULL DEFAULT '{}'::jsonb,
to_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
cc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
subject text NOT NULL DEFAULT '',
snippet text NOT NULL DEFAULT '',
text_body text NOT NULL DEFAULT '',
html_body text NOT NULL DEFAULT '',
received_at timestamptz NOT NULL,
is_read boolean NOT NULL DEFAULT false,
linked_task_id text,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS mail_messages_mailbox_folder_uid_idx ON mail_messages (mailbox_id, folder, remote_uid);
CREATE INDEX IF NOT EXISTS mail_messages_workspace_received_idx ON mail_messages (workspace_slug, received_at DESC);
CREATE TABLE IF NOT EXISTS outgoing_mails (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
mailbox_id text NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
to_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
cc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
bcc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
subject text NOT NULL DEFAULT '',
text_body text NOT NULL DEFAULT '',
html_body text NOT NULL DEFAULT '',
status text NOT NULL,
scheduled_for timestamptz,
sent_at timestamptz,
error_message text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS outgoing_mails_workspace_created_idx ON outgoing_mails (workspace_slug, created_at DESC);
CREATE INDEX IF NOT EXISTS outgoing_mails_status_schedule_idx ON outgoing_mails (status, scheduled_for ASC);
-- +goose Down
DROP TABLE IF EXISTS outgoing_mails;
DROP TABLE IF EXISTS mail_messages;
DROP TABLE IF EXISTS mailboxes;
@@ -0,0 +1,79 @@
-- +goose Up
-- Contacts
CREATE TABLE IF NOT EXISTS contacts (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
first_name text NOT NULL DEFAULT '',
last_name text NOT NULL DEFAULT '',
email text NOT NULL DEFAULT '',
phone text NOT NULL DEFAULT '',
company_id text,
title text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
avatar_url text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS contacts_workspace_updated_idx ON contacts (workspace_slug, updated_at DESC);
CREATE INDEX IF NOT EXISTS contacts_workspace_email_idx ON contacts (workspace_slug, email);
-- Companies
CREATE TABLE IF NOT EXISTS companies (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
domain text NOT NULL DEFAULT '',
website text NOT NULL DEFAULT '',
industry text NOT NULL DEFAULT '',
size text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
logo_url text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS companies_workspace_updated_idx ON companies (workspace_slug, updated_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS companies_workspace_name_idx ON companies (workspace_slug, name);
-- Add company foreign key to contacts
ALTER TABLE contacts ADD CONSTRAINT contacts_company_fk FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
-- Contact-Task links
CREATE TABLE IF NOT EXISTS contact_tasks (
id text PRIMARY KEY,
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
task_id text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS contact_tasks_unique_idx ON contact_tasks (contact_id, task_id);
-- Contact-Event links
CREATE TABLE IF NOT EXISTS contact_events (
id text PRIMARY KEY,
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
event_id text NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS contact_events_unique_idx ON contact_events (contact_id, event_id);
-- Contact-Email links (track which contacts are involved in emails)
CREATE TABLE IF NOT EXISTS contact_emails (
id text PRIMARY KEY,
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
mail_message_id text NOT NULL REFERENCES mail_messages(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'recipient', -- sender, recipient, cc
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS contact_emails_unique_idx ON contact_emails (contact_id, mail_message_id);
-- +goose Down
DROP TABLE IF EXISTS contact_emails;
DROP TABLE IF EXISTS contact_events;
DROP TABLE IF EXISTS contact_tasks;
ALTER TABLE contacts DROP CONSTRAINT IF EXISTS contacts_company_fk;
DROP TABLE IF EXISTS contacts;
DROP TABLE IF EXISTS companies;
@@ -0,0 +1,71 @@
-- +goose Up
-- Recurring tasks
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_rule text NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_end timestamptz;
-- Recurring events
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS recurrence_rule text NOT NULL DEFAULT '';
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS recurrence_end timestamptz;
-- Quick capture inbox
CREATE TABLE IF NOT EXISTS inbox_items (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
content text NOT NULL,
source text NOT NULL DEFAULT 'manual', -- manual, email, api
processed boolean NOT NULL DEFAULT false,
processed_at timestamptz,
processed_entity_type text, -- task, note, event
processed_entity_id text,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS inbox_items_workspace_created_idx ON inbox_items (workspace_slug, created_at DESC);
-- Time tracking
CREATE TABLE IF NOT EXISTS time_entries (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
task_id text REFERENCES tasks(id) ON DELETE CASCADE,
description text NOT NULL DEFAULT '',
started_at timestamptz NOT NULL,
ended_at timestamptz,
duration_seconds integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS time_entries_workspace_started_idx ON time_entries (workspace_slug, started_at DESC);
CREATE INDEX IF NOT EXISTS time_entries_task_idx ON time_entries (task_id);
-- Saved filters/views
CREATE TABLE IF NOT EXISTS saved_views (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
entity_type text NOT NULL, -- tasks, contacts, companies
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
sort_json jsonb NOT NULL DEFAULT '{}'::jsonb,
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS saved_views_workspace_type_idx ON saved_views (workspace_slug, entity_type);
-- Archive support
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE tasks DROP COLUMN IF EXISTS recurrence_rule;
ALTER TABLE tasks DROP COLUMN IF EXISTS recurrence_end;
ALTER TABLE calendar_events DROP COLUMN IF EXISTS recurrence_rule;
ALTER TABLE calendar_events DROP COLUMN IF EXISTS recurrence_end;
DROP TABLE IF EXISTS inbox_items;
DROP TABLE IF EXISTS time_entries;
DROP TABLE IF EXISTS saved_views;
ALTER TABLE tasks DROP COLUMN IF EXISTS archived;
ALTER TABLE calendar_events DROP COLUMN IF EXISTS archived;
ALTER TABLE notes DROP COLUMN IF EXISTS archived;
@@ -0,0 +1,70 @@
-- +goose Up
-- Integrations table for external service connections
CREATE TABLE IF NOT EXISTS integrations (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
provider text NOT NULL, -- google_calendar, slack, etc.
name text NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
credentials_ciphertext text NOT NULL,
status text NOT NULL DEFAULT 'active',
last_sync_at timestamptz,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS integrations_workspace_provider_idx ON integrations (workspace_slug, provider);
-- Webhooks for external notifications
CREATE TABLE IF NOT EXISTS webhooks (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
url text NOT NULL,
secret text NOT NULL,
events jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["task.created", "task.completed", etc.]
active boolean NOT NULL DEFAULT true,
last_triggered_at timestamptz,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS webhooks_workspace_idx ON webhooks (workspace_slug);
-- Notifications for users
CREATE TABLE IF NOT EXISTS notifications (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
user_email text NOT NULL,
type text NOT NULL, -- task_assigned, mention, comment, etc.
title text NOT NULL,
body text NOT NULL DEFAULT '',
entity_type text, -- task, event, note
entity_id text,
read boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications (user_email, created_at DESC);
CREATE INDEX IF NOT EXISTS notifications_unread_idx ON notifications (user_email, read) WHERE read = false;
-- Presence tracking for real-time collaboration
CREATE TABLE IF NOT EXISTS presence (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
user_email text NOT NULL,
user_name text NOT NULL,
entity_type text, -- board, task, note, etc.
entity_id text,
last_seen_at timestamptz NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS presence_workspace_entity_idx ON presence (workspace_slug, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS presence_user_idx ON presence (user_email);
-- +goose Down
DROP TABLE IF EXISTS presence;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS webhooks;
DROP TABLE IF EXISTS integrations;
+217
View File
@@ -0,0 +1,217 @@
-- name: CountWorkspaces :one
SELECT COUNT(*) FROM workspaces;
-- name: ListWorkspaces :many
SELECT id, slug, name, role, created_at
FROM workspaces
ORDER BY created_at ASC;
-- name: CreateWorkspace :exec
INSERT INTO workspaces (id, slug, name, role, created_at)
VALUES ($1, $2, $3, $4, $5);
-- name: ListMembers :many
SELECT id, workspace_slug, name, email, role, status
FROM members
WHERE workspace_slug = $1
ORDER BY name ASC;
-- name: CreateMember :exec
INSERT INTO members (id, workspace_slug, name, email, role, status)
VALUES ($1, $2, $3, $4, $5, $6);
-- name: GetMemberByWorkspaceAndEmail :one
SELECT id, workspace_slug, name, email, role, status
FROM members
WHERE workspace_slug = $1 AND email = $2;
-- name: ListInvites :many
SELECT id, workspace_slug, email, role, token, created_at, status
FROM invites
WHERE workspace_slug = $1
ORDER BY created_at DESC;
-- name: GetInviteByToken :one
SELECT id, workspace_slug, email, role, token, created_at, status
FROM invites
WHERE token = $1;
-- name: CreateInvite :one
INSERT INTO invites (id, workspace_slug, email, role, token, created_at, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, workspace_slug, email, role, token, created_at, status;
-- name: AcceptInvite :one
UPDATE invites
SET status = 'accepted'
WHERE token = $1
RETURNING id, workspace_slug, email, role, token, created_at, status;
-- name: ListActivities :many
SELECT id, workspace_slug, title, detail, created_at
FROM activity_entries
WHERE workspace_slug = $1
ORDER BY created_at DESC
LIMIT 40;
-- name: CreateActivity :exec
INSERT INTO activity_entries (id, workspace_slug, title, detail, created_at)
VALUES ($1, $2, $3, $4, $5);
-- name: TrimActivities :exec
DELETE FROM activity_entries
WHERE id IN (
SELECT id
FROM activity_entries
WHERE activity_entries.workspace_slug = $1
ORDER BY created_at DESC
OFFSET 40
);
-- name: ListBoardGroups :many
SELECT id, workspace_slug, name, color, sort_order
FROM board_groups
WHERE workspace_slug = $1
ORDER BY sort_order ASC, name ASC;
-- name: GetBoardGroupByID :one
SELECT id, workspace_slug, name, color, sort_order
FROM board_groups
WHERE id = $1;
-- name: CreateBoardGroup :one
INSERT INTO board_groups (id, workspace_slug, name, color, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_slug, name, color, sort_order;
-- name: UpdateBoardGroup :one
UPDATE board_groups
SET name = $2,
color = $3,
sort_order = $4
WHERE id = $1
RETURNING id, workspace_slug, name, color, sort_order;
-- name: CountBoardGroupsByWorkspace :one
SELECT COUNT(*) FROM board_groups WHERE workspace_slug = $1;
-- name: ListLabels :many
SELECT id, workspace_slug, name, color
FROM labels
WHERE workspace_slug = $1
ORDER BY name ASC;
-- name: CreateLabel :one
INSERT INTO labels (id, workspace_slug, name, color)
VALUES ($1, $2, $3, $4)
RETURNING id, workspace_slug, name, color;
-- name: ListTasks :many
SELECT id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at
FROM tasks
WHERE workspace_slug = $1
ORDER BY updated_at DESC;
-- name: GetTaskByID :one
SELECT id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at
FROM tasks
WHERE id = $1;
-- name: CreateTask :one
INSERT INTO tasks (id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at;
-- name: UpdateTask :one
UPDATE tasks
SET title = $2,
description = $3,
status = $4,
board_group_id = $5,
color = $6,
due_at = $7,
scheduled_start = $8,
scheduled_end = $9,
assignee_id = $10,
label_ids = $11,
attachments = $12,
comments = $13,
updated_at = $14
WHERE id = $1
RETURNING id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at;
-- name: ListCalendarEvents :many
SELECT id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments
FROM calendar_events
WHERE workspace_slug = $1
ORDER BY starts_at ASC;
-- name: GetCalendarEventByID :one
SELECT id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments
FROM calendar_events
WHERE id = $1;
-- name: CreateCalendarEvent :one
INSERT INTO calendar_events (id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments;
-- name: UpdateCalendarEvent :one
UPDATE calendar_events
SET title = $2,
description = $3,
starts_at = $4,
ends_at = $5,
color = $6,
linked_task_id = $7,
attachments = $8
WHERE id = $1
RETURNING id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments;
-- name: ListNotes :many
SELECT id, workspace_slug, title, content, updated_at
FROM notes
WHERE workspace_slug = $1
ORDER BY updated_at DESC;
-- name: GetNoteByID :one
SELECT id, workspace_slug, title, content, updated_at
FROM notes
WHERE id = $1;
-- name: CreateNote :one
INSERT INTO notes (id, workspace_slug, title, content, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_slug, title, content, updated_at;
-- name: UpdateNote :one
UPDATE notes
SET title = $2,
content = $3,
updated_at = $4
WHERE id = $1
RETURNING id, workspace_slug, title, content, updated_at;
-- name: ListFocusSessions :many
SELECT id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds
FROM focus_sessions
WHERE workspace_slug = $1
ORDER BY started_at DESC;
-- name: GetFocusSessionByID :one
SELECT id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds
FROM focus_sessions
WHERE id = $1;
-- name: CreateFocusSession :one
INSERT INTO focus_sessions (id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds;
-- name: UpdateFocusSession :one
UPDATE focus_sessions
SET completed_at = $2,
paused_at = $3,
paused_total_seconds = $4
WHERE id = $1
RETURNING id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds;
@@ -0,0 +1,86 @@
package filestorage
import (
"context"
"errors"
"io"
"os"
"path/filepath"
)
type LocalStorage struct {
root string
}
func NewLocal(root string) *LocalStorage {
return &LocalStorage{root: root}
}
func (l *LocalStorage) Provider() string {
return "local"
}
func (l *LocalStorage) Probe(_ context.Context) error {
if err := os.MkdirAll(l.root, 0o755); err != nil {
return err
}
probePath := filepath.Join(l.root, ".healthcheck")
file, err := os.OpenFile(probePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
_ = os.Remove(probePath)
return nil
}
func (l *LocalStorage) Put(_ context.Context, key string, reader io.Reader, _ string, _ int64) error {
path := filepath.Join(l.root, filepath.Clean(key))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(file, reader); err != nil {
_ = file.Close()
_ = os.Remove(path)
return err
}
return file.Close()
}
func (l *LocalStorage) Get(_ context.Context, key string) (Object, error) {
path := filepath.Join(l.root, filepath.Clean(key))
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Object{}, ErrNotFound
}
return Object{}, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return Object{}, err
}
if info.IsDir() {
_ = file.Close()
return Object{}, ErrNotFound
}
return Object{
Body: file,
Size: info.Size(),
}, nil
}
func (l *LocalStorage) Delete(_ context.Context, key string) error {
path := filepath.Join(l.root, filepath.Clean(key))
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package filestorage
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
type S3Storage struct {
client *s3.Client
bucket string
}
func NewS3FromEnv() (*S3Storage, error) {
bucket := strings.TrimSpace(os.Getenv("S3_BUCKET"))
accessKey := strings.TrimSpace(os.Getenv("S3_ACCESS_KEY"))
secretKey := strings.TrimSpace(os.Getenv("S3_SECRET_KEY"))
if bucket == "" || accessKey == "" || secretKey == "" {
return nil, errors.New("S3_BUCKET, S3_ACCESS_KEY, and S3_SECRET_KEY are required for FILE_STORAGE_PROVIDER=s3")
}
region := strings.TrimSpace(os.Getenv("S3_REGION"))
if region == "" {
region = "us-east-1"
}
endpoint := strings.TrimSpace(os.Getenv("S3_ENDPOINT"))
usePathStyle, _ := strconv.ParseBool(strings.TrimSpace(os.Getenv("S3_USE_PATH_STYLE")))
cfg := aws.Config{
Region: region,
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
}
client := s3.NewFromConfig(cfg, func(options *s3.Options) {
options.UsePathStyle = usePathStyle
if endpoint != "" {
options.BaseEndpoint = aws.String(endpoint)
}
})
return &S3Storage{client: client, bucket: bucket}, nil
}
func (s *S3Storage) Provider() string {
return "s3"
}
func (s *S3Storage) Probe(ctx context.Context) error {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
return err
}
func (s *S3Storage) Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: reader,
ContentType: aws.String(contentType),
ContentLength: aws.Int64(size),
})
return err
}
func (s *S3Storage) Get(ctx context.Context, key string) (Object, error) {
response, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFoundError(err) {
return Object{}, ErrNotFound
}
return Object{}, err
}
contentType := ""
if response.ContentType != nil {
contentType = *response.ContentType
}
size := int64(0)
if response.ContentLength != nil {
size = *response.ContentLength
}
return Object{
Body: response.Body,
ContentType: contentType,
Size: size,
}, nil
}
func (s *S3Storage) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFoundError(err) {
return nil
}
return fmt.Errorf("delete s3 object: %w", err)
}
return nil
}
func isS3NotFoundError(err error) bool {
var noSuchKey *types.NoSuchKey
if errors.As(err, &noSuchKey) {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "NoSuchKey", "NotFound", "NoSuchBucket":
return true
}
}
return false
}
@@ -0,0 +1,40 @@
package filestorage
import (
"context"
"errors"
"io"
"os"
"strings"
)
var ErrNotFound = errors.New("file storage object not found")
type Object struct {
Body io.ReadCloser
ContentType string
Size int64
}
type Storage interface {
Provider() string
Probe(ctx context.Context) error
Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error
Get(ctx context.Context, key string) (Object, error)
Delete(ctx context.Context, key string) error
}
func NewFromEnv() (Storage, error) {
provider := strings.ToLower(strings.TrimSpace(os.Getenv("FILE_STORAGE_PROVIDER")))
if provider == "" || provider == "local" {
root := strings.TrimSpace(os.Getenv("FILE_STORAGE_DIR"))
if root == "" {
root = "./data/uploads"
}
return NewLocal(root), nil
}
if provider == "s3" {
return NewS3FromEnv()
}
return nil, errors.New("unsupported file storage provider")
}
@@ -0,0 +1,107 @@
package httpapi
import (
"fmt"
"strconv"
"strings"
"productier/apps/backend/internal/store"
)
const (
defaultActivityLimit = 8
maxActivityLimit = 40
)
var activityTypes = map[string]struct{}{
"task": {},
"board": {},
"calendar": {},
"note": {},
"focus": {},
"mail": {},
"invite": {},
"system": {},
}
type activityListParams struct {
Limit int
Type string
Query string
}
func parseActivityListParams(limitRaw string, typeRaw string, queryRaw string) (activityListParams, error) {
params := activityListParams{
Limit: defaultActivityLimit,
Type: strings.TrimSpace(strings.ToLower(typeRaw)),
Query: strings.TrimSpace(strings.ToLower(queryRaw)),
}
if strings.TrimSpace(limitRaw) != "" {
parsed, err := strconv.Atoi(limitRaw)
if err != nil {
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
}
if parsed < 1 || parsed > maxActivityLimit {
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
}
params.Limit = parsed
}
if params.Type != "" {
if _, ok := activityTypes[params.Type]; !ok {
return activityListParams{}, fmt.Errorf("invalid activity type")
}
}
return params, nil
}
func filterActivityEntries(entries []store.ActivityEntry, params activityListParams) []store.ActivityEntry {
if len(entries) == 0 || params.Limit == 0 {
return []store.ActivityEntry{}
}
filtered := make([]store.ActivityEntry, 0, params.Limit)
for _, entry := range entries {
if params.Type != "" && classifyActivityEntry(entry) != params.Type {
continue
}
if params.Query != "" {
title := strings.ToLower(entry.Title)
detail := strings.ToLower(entry.Detail)
if !strings.Contains(title, params.Query) && !strings.Contains(detail, params.Query) {
continue
}
}
filtered = append(filtered, entry)
if len(filtered) == params.Limit {
break
}
}
return filtered
}
func classifyActivityEntry(entry store.ActivityEntry) string {
text := strings.ToLower(strings.TrimSpace(entry.Title + " " + entry.Detail))
switch {
case strings.Contains(text, "invite"):
return "invite"
case strings.Contains(text, "board"):
return "board"
case strings.Contains(text, "mail"), strings.Contains(text, "inbox"), strings.Contains(text, "smtp"), strings.Contains(text, "imap"):
return "mail"
case strings.Contains(text, "calendar"), strings.Contains(text, "event"):
return "calendar"
case strings.Contains(text, "note"):
return "note"
case strings.Contains(text, "focus"), strings.Contains(text, "pomodoro"):
return "focus"
case strings.Contains(text, "task"):
return "task"
default:
return "system"
}
}
@@ -0,0 +1,96 @@
package httpapi
import (
"testing"
"time"
"productier/apps/backend/internal/store"
)
func TestParseActivityListParams(t *testing.T) {
t.Parallel()
params, err := parseActivityListParams("", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if params.Limit != defaultActivityLimit {
t.Fatalf("default limit = %d, want %d", params.Limit, defaultActivityLimit)
}
params, err = parseActivityListParams("12", "task", "foo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if params.Limit != 12 || params.Type != "task" || params.Query != "foo" {
t.Fatalf("unexpected parsed params: %+v", params)
}
if _, err := parseActivityListParams("0", "", ""); err == nil {
t.Fatal("expected error for out-of-range limit")
}
if _, err := parseActivityListParams("abc", "", ""); err == nil {
t.Fatal("expected error for non-numeric limit")
}
if _, err := parseActivityListParams("5", "unknown", ""); err == nil {
t.Fatal("expected error for invalid activity type")
}
}
func TestFilterActivityEntries(t *testing.T) {
t.Parallel()
entries := []store.ActivityEntry{
{ID: "1", Title: "Task created", Detail: "Write docs", CreatedAt: time.Now().Add(-1 * time.Hour)},
{ID: "2", Title: "Invite accepted", Detail: "alex@example.com joined", CreatedAt: time.Now().Add(-2 * time.Hour)},
{ID: "3", Title: "Mail synced", Detail: "Inbox updated", CreatedAt: time.Now().Add(-3 * time.Hour)},
}
filtered := filterActivityEntries(entries, activityListParams{
Limit: 5,
Type: "invite",
})
if len(filtered) != 1 || filtered[0].ID != "2" {
t.Fatalf("expected invite entry only, got %+v", filtered)
}
filtered = filterActivityEntries(entries, activityListParams{
Limit: 5,
Query: "docs",
})
if len(filtered) != 1 || filtered[0].ID != "1" {
t.Fatalf("expected docs match only, got %+v", filtered)
}
filtered = filterActivityEntries(entries, activityListParams{
Limit: 2,
})
if len(filtered) != 2 {
t.Fatalf("expected two entries due to limit, got %d", len(filtered))
}
}
func TestClassifyActivityEntry(t *testing.T) {
t.Parallel()
cases := []struct {
entry store.ActivityEntry
want string
}{
{entry: store.ActivityEntry{Title: "Task updated", Detail: "Done"}, want: "task"},
{entry: store.ActivityEntry{Title: "Board group added", Detail: "Inbox"}, want: "board"},
{entry: store.ActivityEntry{Title: "Event moved", Detail: "Calendar item"}, want: "calendar"},
{entry: store.ActivityEntry{Title: "Note saved", Detail: "Draft"}, want: "note"},
{entry: store.ActivityEntry{Title: "Focus started", Detail: "Pomodoro"}, want: "focus"},
{entry: store.ActivityEntry{Title: "Mail sent", Detail: "SMTP ok"}, want: "mail"},
{entry: store.ActivityEntry{Title: "Invite revoked", Detail: "guest"}, want: "invite"},
{entry: store.ActivityEntry{Title: "Workspace synced", Detail: "ok"}, want: "system"},
}
for _, test := range cases {
got := classifyActivityEntry(test.entry)
if got != test.want {
t.Fatalf("classifyActivityEntry(%q) = %q, want %q", test.entry.Title, got, test.want)
}
}
}
+214
View File
@@ -0,0 +1,214 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/store"
)
func (s *Server) registerCRMRoutes(group *gin.RouterGroup) {
// Contacts
group.GET("/contacts", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListContacts(workspaceSlug)})
})
group.GET("/contacts/:contactId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": contact})
})
group.POST("/contacts", func(c *gin.Context) {
var input store.CreateContactInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
contact := s.store.CreateContact(input)
c.JSON(http.StatusCreated, gin.H{"data": contact})
})
group.PATCH("/contacts/:contactId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
var input store.UpdateContactInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateContact(c.Param("contactId"), input)
if err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.DELETE("/contacts/:contactId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.DeleteContact(c.Param("contactId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Companies
group.GET("/companies", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListCompanies(workspaceSlug)})
})
group.GET("/companies/:companyId", func(c *gin.Context) {
company, err := s.store.GetCompanyByID(c.Param("companyId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": company})
})
group.POST("/companies", func(c *gin.Context) {
var input store.CreateCompanyInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
company := s.store.CreateCompany(input)
c.JSON(http.StatusCreated, gin.H{"data": company})
})
group.PATCH("/companies/:companyId", func(c *gin.Context) {
company, err := s.store.GetCompanyByID(c.Param("companyId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
return
}
var input store.UpdateCompanyInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateCompany(c.Param("companyId"), input)
if err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.DELETE("/companies/:companyId", func(c *gin.Context) {
company, err := s.store.GetCompanyByID(c.Param("companyId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
return
}
if err := s.store.DeleteCompany(c.Param("companyId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Contact-Task linking
group.POST("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.LinkContactToTask(c.Param("contactId"), c.Param("taskId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.DELETE("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.UnlinkContactFromTask(c.Param("contactId"), c.Param("taskId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Contact-Event linking
group.POST("/contacts/:contactId/events/:eventId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.LinkContactToEvent(c.Param("contactId"), c.Param("eventId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
+62
View File
@@ -0,0 +1,62 @@
package httpapi
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const requestIDContextKey = "requestID"
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := strings.TrimSpace(c.GetHeader("X-Request-Id"))
if requestID == "" {
requestID = uuid.NewString()
}
c.Set(requestIDContextKey, requestID)
c.Header("X-Request-Id", requestID)
c.Next()
}
}
func requestIDFromContext(c *gin.Context) string {
if value, exists := c.Get(requestIDContextKey); exists {
if requestID, ok := value.(string); ok {
return requestID
}
}
return ""
}
func (s *Server) writeStatusError(c *gin.Context, status int, message string) {
code := "internal_error"
switch status {
case http.StatusBadRequest:
code = "bad_request"
case http.StatusUnauthorized:
code = "unauthorized"
case http.StatusForbidden:
code = "forbidden"
case http.StatusNotFound:
code = "not_found"
case http.StatusConflict:
code = "conflict"
case http.StatusBadGateway:
code = "upstream_error"
case http.StatusServiceUnavailable:
code = "service_unavailable"
}
if strings.TrimSpace(message) == "" {
message = http.StatusText(status)
}
c.JSON(status, gin.H{
"error": gin.H{
"code": code,
"message": message,
"requestId": requestIDFromContext(c),
},
})
}
@@ -0,0 +1,175 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/store"
)
func (s *Server) registerIntegrationRoutes(group *gin.RouterGroup) {
// Integrations
group.GET("/integrations", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListIntegrations(workspaceSlug)})
})
group.POST("/integrations", func(c *gin.Context) {
var input store.CreateIntegrationInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
integration := s.store.CreateIntegration(input)
c.JSON(http.StatusCreated, gin.H{"data": integration})
})
group.DELETE("/integrations/:integrationId", func(c *gin.Context) {
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
return
}
if err := s.store.DeleteIntegration(integration.ID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Webhooks
group.GET("/webhooks", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListWebhooks(workspaceSlug)})
})
group.POST("/webhooks", func(c *gin.Context) {
var input store.CreateWebhookInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
webhook := s.store.CreateWebhook(input)
c.JSON(http.StatusCreated, gin.H{"data": webhook})
})
group.DELETE("/webhooks/:webhookId", func(c *gin.Context) {
if err := s.store.DeleteWebhook(c.Param("webhookId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Notifications
group.GET("/notifications", func(c *gin.Context) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
limit := 50
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotifications(user.Email, limit)})
})
group.POST("/notifications/:notificationId/read", func(c *gin.Context) {
if err := s.store.MarkNotificationRead(c.Param("notificationId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.POST("/notifications/read-all", func(c *gin.Context) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
if err := s.store.MarkAllNotificationsRead(user.Email); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.GET("/notifications/unread-count", func(c *gin.Context) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
c.JSON(http.StatusOK, gin.H{"count": s.store.UnreadNotificationCount(user.Email)})
})
// Presence
group.POST("/presence", func(c *gin.Context) {
var input store.UpdatePresenceInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
presence := s.store.UpdatePresence(input)
c.JSON(http.StatusOK, gin.H{"data": presence})
})
// Create notification (internal use)
group.POST("/notifications", func(c *gin.Context) {
var input store.CreateNotificationInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
notification := s.store.CreateNotification(input)
c.JSON(http.StatusCreated, gin.H{"data": notification})
})
group.GET("/presence", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
entityType := c.Query("entityType")
entityID := c.Query("entityId")
c.JSON(http.StatusOK, gin.H{"data": s.store.ListPresence(workspaceSlug, entityType, entityID)})
})
group.DELETE("/presence", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
if err := s.store.ClearPresence(workspaceSlug, user.Email); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
+57
View File
@@ -0,0 +1,57 @@
package httpapi
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func requestLogMiddleware(logger *zap.Logger) gin.HandlerFunc {
baseLogger := logger
if baseLogger == nil {
baseLogger = zap.NewNop()
}
return func(c *gin.Context) {
startedAt := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
status := c.Writer.Status()
latency := time.Since(startedAt)
requestID := requestIDFromContext(c)
if path == "/v1/health" && status < 400 {
return
}
fields := []zap.Field{
zap.String("requestId", requestID),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.Int("status", status),
zap.Duration("latency", latency),
zap.String("clientIP", c.ClientIP()),
zap.String("userAgent", c.Request.UserAgent()),
zap.Int("responseBytes", c.Writer.Size()),
}
if query != "" {
fields = append(fields, zap.String("query", query))
}
if len(c.Errors) > 0 {
fields = append(fields, zap.String("errors", c.Errors.String()))
}
switch {
case status >= 500:
baseLogger.Error("http request completed with server error", fields...)
case status >= 400:
baseLogger.Warn("http request completed with client error", fields...)
default:
baseLogger.Info("http request completed", fields...)
}
}
}
@@ -0,0 +1,229 @@
package httpapi
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/mailruntime"
"productier/apps/backend/internal/store"
)
type connectMailboxRequest struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Label string `json:"label"`
Email string `json:"email" binding:"required,email"`
DisplayName string `json:"displayName"`
IMAPHost string `json:"imapHost" binding:"required"`
IMAPPort int `json:"imapPort"`
IMAPUsername string `json:"imapUsername"`
IMAPPassword string `json:"imapPassword" binding:"required"`
IMAPUseTLS bool `json:"imapUseTls"`
SMTPHost string `json:"smtpHost" binding:"required"`
SMTPPort int `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPUseTLS bool `json:"smtpUseTls"`
}
type createOutgoingMailRequest struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
MailboxID string `json:"mailboxId" binding:"required"`
To []store.MailAddress `json:"to" binding:"required"`
Cc []store.MailAddress `json:"cc"`
Bcc []store.MailAddress `json:"bcc"`
Subject string `json:"subject"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody"`
ScheduledFor *time.Time `json:"scheduledFor"`
}
type createTaskFromMailRequest struct {
BoardGroupID string `json:"boardGroupId" binding:"required"`
Title string `json:"title"`
DueAt *time.Time `json:"dueAt"`
Color string `json:"color"`
}
func (s *Server) registerMailRoutes(group *gin.RouterGroup) {
group.GET("/mailboxes", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailboxes(workspaceSlug)})
})
group.POST("/mailboxes", func(c *gin.Context) {
var input connectMailboxRequest
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
mailbox, err := s.mail.ConnectMailbox(c.Request.Context(), mailruntime.ConnectMailboxInput{
WorkspaceSlug: input.WorkspaceSlug,
Label: input.Label,
Email: input.Email,
DisplayName: input.DisplayName,
IMAPHost: input.IMAPHost,
IMAPPort: input.IMAPPort,
IMAPUsername: input.IMAPUsername,
IMAPPassword: input.IMAPPassword,
IMAPUseTLS: input.IMAPUseTLS,
SMTPHost: input.SMTPHost,
SMTPPort: input.SMTPPort,
SMTPUsername: input.SMTPUsername,
SMTPPassword: input.SMTPPassword,
SMTPUseTLS: input.SMTPUseTLS,
})
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{"data": mailbox})
})
group.POST("/mailboxes/:mailboxId/sync", func(c *gin.Context) {
mailbox, err := s.store.GetMailboxByID(c.Param("mailboxId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, mailbox.WorkspaceSlug); !ok {
return
}
if err := s.mail.SyncMailbox(c.Request.Context(), mailbox.ID); err != nil {
s.writeStatusError(c, http.StatusBadGateway, err.Error())
return
}
updated, err := s.store.GetMailboxByID(mailbox.ID)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.GET("/mail/messages", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailMessages(workspaceSlug, c.Query("mailboxId"))})
})
group.GET("/mail/outgoing", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListOutgoingMails(workspaceSlug, c.Query("mailboxId"))})
})
group.POST("/mail/outgoing", func(c *gin.Context) {
var input createOutgoingMailRequest
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
mailbox, err := s.store.GetMailboxByID(input.MailboxID)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if mailbox.WorkspaceSlug != input.WorkspaceSlug {
s.writeStatusError(c, http.StatusForbidden, "mailbox does not belong to workspace")
return
}
item, err := s.mail.QueueOutgoingMail(c.Request.Context(), mailruntime.QueueOutgoingMailInput{
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: input.MailboxID,
To: input.To,
Cc: input.Cc,
Bcc: input.Bcc,
Subject: input.Subject,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
ScheduledFor: input.ScheduledFor,
})
if err != nil {
s.writeStatusError(c, http.StatusBadGateway, err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
})
group.POST("/mail/messages/:messageId/create-task", func(c *gin.Context) {
message, err := s.store.GetMailMessageByID(c.Param("messageId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, message.WorkspaceSlug); !ok {
return
}
if message.LinkedTaskID != nil {
s.writeStatusError(c, http.StatusConflict, "message already linked to a task")
return
}
var input createTaskFromMailRequest
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
description := mailTaskDescription(message)
task := s.store.CreateTask(store.CreateTaskInput{
WorkspaceSlug: message.WorkspaceSlug,
BoardGroupID: input.BoardGroupID,
Title: firstNonBlank(strings.TrimSpace(input.Title), strings.TrimSpace(message.Subject), "Follow up on email"),
Description: description,
DueAt: input.DueAt,
Color: withFallback(input.Color, "blue"),
})
if _, err := s.store.LinkMailMessageTask(message.ID, task.ID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{"data": task})
})
}
func mailTaskDescription(message store.MailMessage) string {
var builder strings.Builder
if message.From.Email != "" {
builder.WriteString(fmt.Sprintf("From: %s <%s>\n\n", firstNonBlank(message.From.Name, "Sender"), message.From.Email))
}
body := firstNonBlank(strings.TrimSpace(message.TextBody), strings.TrimSpace(message.Snippet))
builder.WriteString(body)
return strings.TrimSpace(builder.String())
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func withFallback(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
+263
View File
@@ -0,0 +1,263 @@
package httpapi
import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type routeMetricSnapshot struct {
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
Count uint64 `json:"count"`
AvgLatencyMs float64 `json:"avgLatencyMs"`
MaxLatencyMs float64 `json:"maxLatencyMs"`
LastSeenAt string `json:"lastSeenAt"`
}
type metricsSnapshot struct {
GeneratedAt string `json:"generatedAt"`
UptimeSeconds int64 `json:"uptimeSeconds"`
RequestsTotal uint64 `json:"requestsTotal"`
StatusClassTotals map[string]uint64 `json:"statusClassTotals"`
Routes []routeMetricSnapshot `json:"routes"`
}
type routeMetricBucket struct {
Method string
Path string
Status int
Count uint64
TotalLatencyNanos float64
MaxLatencyNanos float64
LastSeenAt time.Time
}
type requestMetrics struct {
startedAt time.Time
requestsTotal uint64
status2xxTotal uint64
status3xxTotal uint64
status4xxTotal uint64
status5xxTotal uint64
statusOther uint64
mu sync.RWMutex
buckets map[string]*routeMetricBucket
}
func newRequestMetrics() *requestMetrics {
return &requestMetrics{
startedAt: time.Now().UTC(),
buckets: make(map[string]*routeMetricBucket),
}
}
func (m *requestMetrics) observe(method, path string, status int, latency time.Duration) {
if m == nil {
return
}
if path == "" {
path = "<unmatched>"
}
now := time.Now().UTC()
latencyNanos := float64(latency.Nanoseconds())
key := method + " " + path + " " + itoa(status)
m.mu.Lock()
defer m.mu.Unlock()
m.requestsTotal++
switch {
case status >= 200 && status < 300:
m.status2xxTotal++
case status >= 300 && status < 400:
m.status3xxTotal++
case status >= 400 && status < 500:
m.status4xxTotal++
case status >= 500 && status < 600:
m.status5xxTotal++
default:
m.statusOther++
}
bucket, exists := m.buckets[key]
if !exists {
bucket = &routeMetricBucket{
Method: method,
Path: path,
Status: status,
Count: 1,
TotalLatencyNanos: latencyNanos,
MaxLatencyNanos: latencyNanos,
LastSeenAt: now,
}
m.buckets[key] = bucket
return
}
bucket.Count++
bucket.TotalLatencyNanos += latencyNanos
if latencyNanos > bucket.MaxLatencyNanos {
bucket.MaxLatencyNanos = latencyNanos
}
bucket.LastSeenAt = now
}
func (m *requestMetrics) snapshot() metricsSnapshot {
if m == nil {
return metricsSnapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
StatusClassTotals: map[string]uint64{},
Routes: []routeMetricSnapshot{},
}
}
m.mu.RLock()
defer m.mu.RUnlock()
routes := make([]routeMetricSnapshot, 0, len(m.buckets))
for _, bucket := range m.buckets {
avgMs := 0.0
if bucket.Count > 0 {
avgMs = (bucket.TotalLatencyNanos / float64(bucket.Count)) / float64(time.Millisecond)
}
routes = append(routes, routeMetricSnapshot{
Method: bucket.Method,
Path: bucket.Path,
Status: bucket.Status,
Count: bucket.Count,
AvgLatencyMs: avgMs,
MaxLatencyMs: bucket.MaxLatencyNanos / float64(time.Millisecond),
LastSeenAt: bucket.LastSeenAt.Format(time.RFC3339Nano),
})
}
sort.Slice(routes, func(i, j int) bool {
if routes[i].Method != routes[j].Method {
return routes[i].Method < routes[j].Method
}
if routes[i].Path != routes[j].Path {
return routes[i].Path < routes[j].Path
}
return routes[i].Status < routes[j].Status
})
return metricsSnapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(time.Since(m.startedAt).Seconds()),
RequestsTotal: m.requestsTotal,
StatusClassTotals: map[string]uint64{
"2xx": m.status2xxTotal,
"3xx": m.status3xxTotal,
"4xx": m.status4xxTotal,
"5xx": m.status5xxTotal,
"other": m.statusOther,
},
Routes: routes,
}
}
func requestMetricsMiddleware(metrics *requestMetrics) gin.HandlerFunc {
return func(c *gin.Context) {
startedAt := time.Now()
c.Next()
path := c.FullPath()
if path == "" {
path = c.Request.URL.Path
}
if path == "/v1/metrics" || path == "/v1/metrics/prometheus" {
return
}
metrics.observe(c.Request.Method, path, c.Writer.Status(), time.Since(startedAt))
}
}
func (m *requestMetrics) snapshotPrometheus() string {
snapshot := m.snapshot()
var builder strings.Builder
builder.WriteString("# HELP productier_http_uptime_seconds Process uptime in seconds.\n")
builder.WriteString("# TYPE productier_http_uptime_seconds gauge\n")
builder.WriteString("productier_http_uptime_seconds ")
builder.WriteString(strconv.FormatInt(snapshot.UptimeSeconds, 10))
builder.WriteByte('\n')
builder.WriteString("# HELP productier_http_requests_total Total HTTP requests by status class.\n")
builder.WriteString("# TYPE productier_http_requests_total counter\n")
statusClasses := []string{"2xx", "3xx", "4xx", "5xx", "other"}
for _, statusClass := range statusClasses {
builder.WriteString(`productier_http_requests_total{status_class="`)
builder.WriteString(escapePrometheusLabelValue(statusClass))
builder.WriteString(`"} `)
builder.WriteString(strconv.FormatUint(snapshot.StatusClassTotals[statusClass], 10))
builder.WriteByte('\n')
}
builder.WriteString("# HELP productier_http_requests_route_total Total HTTP requests by route and status code.\n")
builder.WriteString("# TYPE productier_http_requests_route_total counter\n")
builder.WriteString("# HELP productier_http_request_latency_avg_ms Average request latency in milliseconds by route and status code.\n")
builder.WriteString("# TYPE productier_http_request_latency_avg_ms gauge\n")
builder.WriteString("# HELP productier_http_request_latency_max_ms Max request latency in milliseconds by route and status code.\n")
builder.WriteString("# TYPE productier_http_request_latency_max_ms gauge\n")
for _, route := range snapshot.Routes {
labels := `method="` + escapePrometheusLabelValue(route.Method) +
`",path="` + escapePrometheusLabelValue(route.Path) +
`",status="` + strconv.Itoa(route.Status) + `"`
builder.WriteString("productier_http_requests_route_total{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatUint(route.Count, 10))
builder.WriteByte('\n')
builder.WriteString("productier_http_request_latency_avg_ms{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatFloat(route.AvgLatencyMs, 'f', 3, 64))
builder.WriteByte('\n')
builder.WriteString("productier_http_request_latency_max_ms{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatFloat(route.MaxLatencyMs, 'f', 3, 64))
builder.WriteByte('\n')
}
return builder.String()
}
func escapePrometheusLabelValue(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return escaped
}
func itoa(value int) string {
if value == 0 {
return "0"
}
isNegative := value < 0
if isNegative {
value = -value
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + (value % 10))
value /= 10
}
if isNegative {
index--
digits[index] = '-'
}
return string(digits[index:])
}
@@ -0,0 +1,31 @@
package httpapi
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) authorizeMetricsRequest(c *gin.Context) bool {
expectedToken := strings.TrimSpace(s.metricsToken)
if expectedToken == "" {
return true
}
providedToken := strings.TrimSpace(c.GetHeader("X-Metrics-Token"))
if providedToken == "" {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
providedToken = strings.TrimSpace(authHeader[len("Bearer "):])
}
}
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
s.writeStatusError(c, http.StatusUnauthorized, "valid metrics token required")
return false
}
return true
}
@@ -0,0 +1,66 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestAuthorizeMetricsRequest(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
createContext := func(headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
request := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
for key, value := range headers {
request.Header.Set(key, value)
}
context.Request = request
return context, recorder
}
t.Run("allows when token unset", func(t *testing.T) {
server := &Server{metricsToken: ""}
context, _ := createContext(nil)
if !server.authorizeMetricsRequest(context) {
t.Fatal("expected request to pass when metrics token is unset")
}
})
t.Run("accepts bearer token", func(t *testing.T) {
server := &Server{metricsToken: "strong-metrics-token"}
context, _ := createContext(map[string]string{
"Authorization": "Bearer strong-metrics-token",
})
if !server.authorizeMetricsRequest(context) {
t.Fatal("expected bearer token to authorize request")
}
})
t.Run("accepts x metrics token", func(t *testing.T) {
server := &Server{metricsToken: "strong-metrics-token"}
context, _ := createContext(map[string]string{
"X-Metrics-Token": "strong-metrics-token",
})
if !server.authorizeMetricsRequest(context) {
t.Fatal("expected X-Metrics-Token to authorize request")
}
})
t.Run("rejects invalid token", func(t *testing.T) {
server := &Server{metricsToken: "strong-metrics-token"}
context, recorder := createContext(map[string]string{
"Authorization": "Bearer wrong-token",
})
if server.authorizeMetricsRequest(context) {
t.Fatal("expected invalid token to be rejected")
}
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
})
}
@@ -0,0 +1,157 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestRequestMetricsObserveAndSnapshot(t *testing.T) {
t.Parallel()
metrics := newRequestMetrics()
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 100*time.Millisecond)
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 200*time.Millisecond)
metrics.observe(http.MethodPost, "/v1/tasks", http.StatusCreated, 40*time.Millisecond)
snapshot := metrics.snapshot()
if snapshot.RequestsTotal != 3 {
t.Fatalf("requestsTotal = %d, want 3", snapshot.RequestsTotal)
}
if snapshot.StatusClassTotals["2xx"] != 3 {
t.Fatalf("2xx total = %d, want 3", snapshot.StatusClassTotals["2xx"])
}
if len(snapshot.Routes) != 2 {
t.Fatalf("route bucket count = %d, want 2", len(snapshot.Routes))
}
if snapshot.UptimeSeconds < 0 {
t.Fatalf("uptimeSeconds = %d, want >= 0", snapshot.UptimeSeconds)
}
health := findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/health", http.StatusOK)
if health == nil {
t.Fatal("missing route metric for GET /v1/health 200")
}
if health.Count != 2 {
t.Fatalf("health count = %d, want 2", health.Count)
}
if health.AvgLatencyMs != 150 {
t.Fatalf("health avgLatencyMs = %.2f, want 150", health.AvgLatencyMs)
}
if health.MaxLatencyMs != 200 {
t.Fatalf("health maxLatencyMs = %.2f, want 200", health.MaxLatencyMs)
}
if _, err := time.Parse(time.RFC3339Nano, health.LastSeenAt); err != nil {
t.Fatalf("health lastSeenAt parse error: %v", err)
}
if _, err := time.Parse(time.RFC3339Nano, snapshot.GeneratedAt); err != nil {
t.Fatalf("snapshot generatedAt parse error: %v", err)
}
}
func TestRequestMetricsMiddlewareSkipsMetricsEndpoint(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
metrics := newRequestMetrics()
router := gin.New()
router.Use(requestMetricsMiddleware(metrics))
router.GET("/v1/health", func(c *gin.Context) {
c.Status(http.StatusOK)
})
router.GET("/v1/metrics", func(c *gin.Context) {
c.Status(http.StatusOK)
})
router.GET("/v1/metrics/prometheus", func(c *gin.Context) {
c.Status(http.StatusOK)
})
healthRequest := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
healthResponse := httptest.NewRecorder()
router.ServeHTTP(healthResponse, healthRequest)
if healthResponse.Code != http.StatusOK {
t.Fatalf("GET /v1/health status = %d, want 200", healthResponse.Code)
}
metricsRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
metricsResponse := httptest.NewRecorder()
router.ServeHTTP(metricsResponse, metricsRequest)
if metricsResponse.Code != http.StatusOK {
t.Fatalf("GET /v1/metrics status = %d, want 200", metricsResponse.Code)
}
prometheusRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics/prometheus", nil)
prometheusResponse := httptest.NewRecorder()
router.ServeHTTP(prometheusResponse, prometheusRequest)
if prometheusResponse.Code != http.StatusOK {
t.Fatalf("GET /v1/metrics/prometheus status = %d, want 200", prometheusResponse.Code)
}
snapshot := metrics.snapshot()
if snapshot.RequestsTotal != 1 {
t.Fatalf("requestsTotal = %d, want 1", snapshot.RequestsTotal)
}
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics", http.StatusOK) != nil {
t.Fatal("metrics endpoint request should be excluded from tracking")
}
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics/prometheus", http.StatusOK) != nil {
t.Fatal("prometheus metrics endpoint request should be excluded from tracking")
}
}
func TestSnapshotPrometheus(t *testing.T) {
t.Parallel()
metrics := newRequestMetrics()
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 50*time.Millisecond)
metrics.observe(http.MethodGet, "/v1/tasks", http.StatusNotFound, 25*time.Millisecond)
metrics.observe(http.MethodGet, `/v1/quoted"path`, http.StatusOK, 35*time.Millisecond)
output := metrics.snapshotPrometheus()
expectedFragments := []string{
"productier_http_uptime_seconds",
`productier_http_requests_total{status_class="2xx"} 2`,
`productier_http_requests_total{status_class="4xx"} 1`,
`productier_http_requests_route_total{method="GET",path="/v1/health",status="200"} 1`,
`productier_http_request_latency_avg_ms{method="GET",path="/v1/health",status="200"} 50.000`,
`productier_http_request_latency_max_ms{method="GET",path="/v1/tasks",status="404"} 25.000`,
`path="/v1/quoted\"path"`,
}
for _, fragment := range expectedFragments {
if !strings.Contains(output, fragment) {
t.Fatalf("expected prometheus output to contain %q\noutput:\n%s", fragment, output)
}
}
}
func TestItoa(t *testing.T) {
t.Parallel()
cases := map[int]string{
0: "0",
7: "7",
42: "42",
-10: "-10",
2048: "2048",
}
for input, want := range cases {
if got := itoa(input); got != want {
t.Fatalf("itoa(%d) = %q, want %q", input, got, want)
}
}
}
func findRouteMetric(routes []routeMetricSnapshot, method, path string, status int) *routeMetricSnapshot {
for _, route := range routes {
if route.Method == method && route.Path == path && route.Status == status {
result := route
return &result
}
}
return nil
}
@@ -0,0 +1,212 @@
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"productier/apps/backend/internal/store"
)
// OAuth state for CSRF protection
type oauthState struct {
State string `json:"state"`
Provider string `json:"provider"`
WorkspaceSlug string `json:"workspaceSlug"`
RedirectURL string `json:"redirectUrl"`
}
// In-memory state store (in production, use Redis or database)
var oauthStates = make(map[string]oauthState)
func (s *Server) registerOAuthRoutes(group *gin.RouterGroup) {
// Google Calendar OAuth
group.GET("/oauth/google-calendar/connect", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
state := uuid.NewString()
redirectURL := c.Query("redirect")
if redirectURL == "" {
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
}
oauthStates[state] = oauthState{
State: state,
Provider: "google_calendar",
WorkspaceSlug: workspaceSlug,
RedirectURL: redirectURL,
}
// Build Google OAuth URL
// In production, use actual OAuth credentials from config
authURL := fmt.Sprintf(
"https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&access_type=offline&prompt=consent",
url.QueryEscape(s.config.GoogleClientID),
url.QueryEscape(s.config.GoogleRedirectURI),
url.QueryEscape("https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly"),
state,
)
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
})
group.GET("/oauth/google-calendar/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
oauthState, exists := oauthStates[state]
if !exists || oauthState.Provider != "google_calendar" {
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
return
}
delete(oauthStates, state)
// Exchange code for tokens
// In production, make actual HTTP request to Google's token endpoint
// For now, we'll create a placeholder integration
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
WorkspaceSlug: oauthState.WorkspaceSlug,
Provider: "google_calendar",
Name: "Google Calendar",
Config: `{"calendar_id": "primary"}`,
Credentials: code, // In production, store actual tokens
})
// Redirect back to the app
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=google_calendar")
})
// Slack OAuth
group.GET("/oauth/slack/connect", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
state := uuid.NewString()
redirectURL := c.Query("redirect")
if redirectURL == "" {
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
}
oauthStates[state] = oauthState{
State: state,
Provider: "slack",
WorkspaceSlug: workspaceSlug,
RedirectURL: redirectURL,
}
// Build Slack OAuth URL
scopes := "chat:write,channels:read,groups:read,im:read"
authURL := fmt.Sprintf(
"https://slack.com/oauth/v2/authorize?client_id=%s&scope=%s&redirect_uri=%s&state=%s",
url.QueryEscape(s.config.SlackClientID),
url.QueryEscape(scopes),
url.QueryEscape(s.config.SlackRedirectURI),
state,
)
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
})
group.GET("/oauth/slack/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
oauthState, exists := oauthStates[state]
if !exists || oauthState.Provider != "slack" {
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
return
}
delete(oauthStates, state)
// Exchange code for tokens
// In production, make actual HTTP request to Slack's token endpoint
// For now, we'll create a placeholder integration
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
WorkspaceSlug: oauthState.WorkspaceSlug,
Provider: "slack",
Name: "Slack",
Config: `{"channel": "general"}`,
Credentials: code, // In production, store actual tokens
})
// Redirect back to the app
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=slack&integration_id="+integration.ID)
})
// Disconnect integration
group.POST("/integrations/:integrationId/disconnect", func(c *gin.Context) {
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
return
}
// In production, revoke OAuth tokens with the provider
// For Google: https://oauth2.googleapis.com/revoke?token=...
// For Slack: https://slack.com/api/auth.revoke?token=...
if err := s.store.DeleteIntegration(integration.ID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
// SyncGoogleCalendar syncs events with Google Calendar
func (s *Server) SyncGoogleCalendar(workspaceSlug string) error {
integrations := s.store.ListIntegrations(workspaceSlug)
for _, integration := range integrations {
if integration.Provider == "google_calendar" && integration.Status == "active" {
// In production, use the stored credentials to:
// 1. Fetch events from Google Calendar
// 2. Create/update events in our database
// 3. Push local events to Google Calendar
// This would be done via the Google Calendar API client
}
}
return nil
}
// SendSlackNotification sends a notification to Slack
func (s *Server) SendSlackNotification(workspaceSlug, channel, message string) error {
integrations := s.store.ListIntegrations(workspaceSlug)
for _, integration := range integrations {
if integration.Provider == "slack" && integration.Status == "active" {
// Parse config to get channel
var config struct {
Channel string `json:"channel"`
}
if err := json.Unmarshal([]byte(integration.Config), &config); err != nil {
continue
}
// In production, use the stored credentials to:
// 1. Post message to Slack channel via webhook or API
// This would be done via the Slack API client
}
}
return nil
}
// Helper to parse JSON config
func parseConfig(configStr string) map[string]interface{} {
var config map[string]interface{}
if err := json.NewDecoder(strings.NewReader(configStr)).Decode(&config); err != nil {
return make(map[string]interface{})
}
return config
}
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/store"
)
func (s *Server) registerProductivityRoutes(group *gin.RouterGroup) {
// Inbox
group.GET("/inbox", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInboxItems(workspaceSlug)})
})
group.POST("/inbox", func(c *gin.Context) {
var input store.CreateInboxItemInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
item := s.store.CreateInboxItem(input)
c.JSON(http.StatusCreated, gin.H{"data": item})
})
group.POST("/inbox/:itemId/process", func(c *gin.Context) {
var input struct {
EntityType string `json:"entityType" binding:"required"`
EntityID string `json:"entityId" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if err := s.store.ProcessInboxItem(c.Param("itemId"), input.EntityType, input.EntityID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.DELETE("/inbox/:itemId", func(c *gin.Context) {
if err := s.store.DeleteInboxItem(c.Param("itemId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Time entries
group.GET("/time-entries", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTimeEntries(workspaceSlug)})
})
group.POST("/time-entries", func(c *gin.Context) {
var input store.CreateTimeEntryInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
entry := s.store.CreateTimeEntry(input)
c.JSON(http.StatusCreated, gin.H{"data": entry})
})
group.PATCH("/time-entries/:entryId", func(c *gin.Context) {
var input store.UpdateTimeEntryInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateTimeEntry(c.Param("entryId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.DELETE("/time-entries/:entryId", func(c *gin.Context) {
if err := s.store.DeleteTimeEntry(c.Param("entryId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Saved views
group.GET("/saved-views", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
entityType := c.Query("entityType")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListSavedViews(workspaceSlug, entityType)})
})
group.POST("/saved-views", func(c *gin.Context) {
var input store.CreateSavedViewInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
view := s.store.CreateSavedView(input)
c.JSON(http.StatusCreated, gin.H{"data": view})
})
group.DELETE("/saved-views/:viewId", func(c *gin.Context) {
if err := s.store.DeleteSavedView(c.Param("viewId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
+570
View File
@@ -0,0 +1,570 @@
package httpapi
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/authsession"
"productier/apps/backend/internal/store"
)
const sessionContextKey = "sessionUser"
func (s *Server) registerRoutes() {
v1 := s.engine.Group("/v1")
{
v1.GET("/health", func(c *gin.Context) {
now := time.Now().UTC()
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
storageStatus := gin.H{
"provider": s.files.Provider(),
"ok": true,
}
if err := s.files.Probe(probeCtx); err != nil {
storageStatus["ok"] = false
storageStatus["error"] = err.Error()
c.JSON(http.StatusServiceUnavailable, gin.H{
"ok": false,
"mode": s.mode,
"timestamp": now,
"storage": storageStatus,
})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"mode": s.mode,
"timestamp": now,
"storage": storageStatus,
})
})
v1.GET("/metrics", func(c *gin.Context) {
if !s.authorizeMetricsRequest(c) {
return
}
c.JSON(http.StatusOK, s.metrics.snapshot())
})
v1.GET("/metrics/prometheus", func(c *gin.Context) {
if !s.authorizeMetricsRequest(c) {
return
}
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(s.metrics.snapshotPrometheus()))
})
v1.GET("/invites/:token", func(c *gin.Context) {
invite, err := s.store.GetInviteByToken(c.Param("token"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": invite})
})
}
authorized := v1.Group("/")
authorized.Use(s.requireSession())
{
authorized.GET("/workspaces", func(c *gin.Context) {
user := s.sessionUser(c)
workspaces := s.store.ListWorkspaces()
visible := make([]store.Workspace, 0, len(workspaces))
for _, workspace := range workspaces {
if _, ok := s.requireWorkspaceMemberByEmail(workspace.Slug, user.Email); ok {
visible = append(visible, workspace)
}
}
c.JSON(http.StatusOK, gin.H{"data": visible})
})
authorized.GET("/members", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMembers(workspaceSlug)})
})
authorized.PATCH("/members/:memberId", func(c *gin.Context) {
member, err := s.store.GetMemberByID(c.Param("memberId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
actingMember, ok := s.requireWorkspaceMember(c, member.WorkspaceSlug)
if !ok {
return
}
if actingMember.Role != "owner" && actingMember.Role != "admin" {
s.writeStatusError(c, http.StatusForbidden, "member management permissions required")
return
}
var input store.UpdateMemberInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateMember(member.ID, input)
if err != nil {
switch err.Error() {
case "invalid member role", "invalid member status":
s.writeStatusError(c, http.StatusBadRequest, err.Error())
case "workspace must have at least one active owner":
s.writeStatusError(c, http.StatusConflict, err.Error())
default:
s.writeStatusError(c, http.StatusNotFound, err.Error())
}
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/invites", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInvites(workspaceSlug)})
})
authorized.POST("/invites", func(c *gin.Context) {
var input store.CreateInviteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
member, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug)
if !ok {
return
}
if member.Role != "owner" && member.Role != "admin" {
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateInvite(input)})
})
authorized.POST("/invites/:token/revoke", func(c *gin.Context) {
invite, err := s.store.GetInviteByID(c.Param("token"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
member, ok := s.requireWorkspaceMember(c, invite.WorkspaceSlug)
if !ok {
return
}
if member.Role != "owner" && member.Role != "admin" {
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
return
}
if err := s.store.RevokeInvite(invite.ID); err != nil {
switch err.Error() {
case "only pending invites can be revoked":
s.writeStatusError(c, http.StatusConflict, err.Error())
default:
s.writeStatusError(c, http.StatusNotFound, err.Error())
}
return
}
c.Status(http.StatusNoContent)
})
authorized.POST("/invites/:token/accept", func(c *gin.Context) {
var input store.AcceptInviteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
user := s.sessionUser(c)
invite, err := s.store.AcceptInvite(c.Param("token"), store.AcceptInviteInput{
Name: user.Name,
Email: user.Email,
})
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": invite})
})
authorized.GET("/activity", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if strings.TrimSpace(workspaceSlug) == "" {
s.writeStatusError(c, http.StatusBadRequest, "workspaceSlug is required")
return
}
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
params, err := parseActivityListParams(c.Query("limit"), c.Query("type"), c.Query("q"))
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
activities := s.store.ListActivities(workspaceSlug)
c.JSON(http.StatusOK, gin.H{"data": filterActivityEntries(activities, params)})
})
authorized.GET("/board-groups", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListBoardGroups(workspaceSlug)})
})
authorized.POST("/board-groups", func(c *gin.Context) {
var input store.CreateBoardGroupInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateBoardGroup(input)})
})
authorized.PATCH("/board-groups/:groupId", func(c *gin.Context) {
group, err := s.store.GetBoardGroupByID(c.Param("groupId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, group.WorkspaceSlug); !ok {
return
}
var input store.UpdateBoardGroupInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateBoardGroup(c.Param("groupId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/labels", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListLabels(workspaceSlug)})
})
authorized.POST("/labels", func(c *gin.Context) {
var input store.CreateLabelInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateLabel(input)})
})
authorized.GET("/tasks", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTasks(workspaceSlug)})
})
authorized.POST("/tasks", func(c *gin.Context) {
var input store.CreateTaskInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
task := s.store.CreateTask(input)
// Trigger webhooks for task creation
s.store.TriggerWebhooks(input.WorkspaceSlug, "task.created", map[string]interface{}{
"taskId": task.ID,
"title": task.Title,
})
c.JSON(http.StatusCreated, gin.H{"data": task})
})
authorized.PATCH("/tasks/:taskId", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
var input store.UpdateTaskInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
// Check if assignee is being set (task assignment notification)
if input.AssigneeID != nil && *input.AssigneeID != "" {
// Get assignee email from member ID
members := s.store.ListMembers(task.WorkspaceSlug)
for _, member := range members {
if member.ID == *input.AssigneeID && member.Status == "active" {
// Create notification for the assignee
s.store.CreateNotificationForTaskAssignment(
task.WorkspaceSlug,
member.Email,
task.Title,
task.ID,
)
break
}
}
}
// Check if status is being changed to done (task completion notification)
if input.Status != nil && *input.Status == "done" && task.Status != "done" && task.AssigneeID != nil {
// Notify the task creator or workspace owner
members := s.store.ListMembers(task.WorkspaceSlug)
for _, member := range members {
if member.Role == "owner" || member.Role == "admin" {
s.store.CreateNotificationForTaskCompletion(
task.WorkspaceSlug,
member.Email,
task.Title,
task.ID,
)
break
}
}
}
updated, err := s.store.UpdateTask(c.Param("taskId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
// Trigger webhooks for task updates
s.store.TriggerWebhooks(task.WorkspaceSlug, "task.updated", map[string]interface{}{
"taskId": task.ID,
"title": task.Title,
})
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/calendar/events", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListEvents(workspaceSlug)})
})
authorized.POST("/calendar/events", func(c *gin.Context) {
var input store.CreateEventInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateEvent(input)})
})
authorized.PATCH("/calendar/events/:eventId", func(c *gin.Context) {
event, err := s.store.GetEventByID(c.Param("eventId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, event.WorkspaceSlug); !ok {
return
}
var input store.UpdateEventInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateEvent(c.Param("eventId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/notes", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotes(workspaceSlug)})
})
authorized.POST("/notes", func(c *gin.Context) {
var input store.CreateNoteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateNote(input)})
})
authorized.PATCH("/notes/:noteId", func(c *gin.Context) {
note, err := s.store.GetNoteByID(c.Param("noteId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, note.WorkspaceSlug); !ok {
return
}
var input store.UpdateNoteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateNote(c.Param("noteId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/focus/sessions", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListFocusSessions(workspaceSlug)})
})
authorized.POST("/focus/sessions", func(c *gin.Context) {
var input store.CreateFocusSessionInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateFocusSession(input)})
})
authorized.PATCH("/focus/sessions/:sessionId", func(c *gin.Context) {
session, err := s.store.GetFocusSessionByID(c.Param("sessionId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, session.WorkspaceSlug); !ok {
return
}
var input store.UpdateFocusSessionInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateFocusSession(c.Param("sessionId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
s.registerTaskAttachmentRoutes(authorized)
s.registerMailRoutes(authorized)
s.registerCRMRoutes(authorized)
s.registerProductivityRoutes(authorized)
s.registerIntegrationRoutes(authorized)
s.registerOAuthRoutes(authorized)
}
}
func (s *Server) requireSession() gin.HandlerFunc {
return func(c *gin.Context) {
user, err := s.authClient.GetUser(c.Request.Context(), c.GetHeader("Cookie"))
if err != nil {
s.writeStatusError(c, http.StatusUnauthorized, "session lookup failed")
c.Abort()
return
}
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
c.Abort()
return
}
c.Set(sessionContextKey, user)
c.Next()
}
}
func (s *Server) sessionUser(c *gin.Context) *authsession.User {
value, exists := c.Get(sessionContextKey)
if !exists {
return nil
}
user, _ := value.(*authsession.User)
return user
}
func (s *Server) requireWorkspaceMember(c *gin.Context, workspaceSlug string) (store.Member, bool) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return store.Member{}, false
}
member, ok := s.requireWorkspaceMemberByEmail(workspaceSlug, user.Email)
if !ok {
s.writeStatusError(c, http.StatusForbidden, "workspace membership required")
return store.Member{}, false
}
return member, true
}
func (s *Server) requireWorkspaceMemberByEmail(workspaceSlug string, email string) (store.Member, bool) {
for _, member := range s.store.ListMembers(workspaceSlug) {
if strings.EqualFold(member.Email, email) && member.Status == "active" {
return member, true
}
}
return store.Member{}, false
}
+96
View File
@@ -0,0 +1,96 @@
package httpapi
import (
"net/http"
"os"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"productier/apps/backend/internal/authsession"
"productier/apps/backend/internal/filestorage"
"productier/apps/backend/internal/mailruntime"
"productier/apps/backend/internal/store"
)
func getEnvOrDefault(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
type OAuthConfig struct {
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURI string
SlackClientID string
SlackClientSecret string
SlackRedirectURI string
}
type Server struct {
engine *gin.Engine
mode string
store store.Store
authClient *authsession.Client
mail *mailruntime.Service
files filestorage.Storage
metrics *requestMetrics
metricsToken string
config OAuthConfig
}
func NewServer(
dataStore store.Store,
authClient *authsession.Client,
mailService *mailruntime.Service,
fileStorage filestorage.Storage,
mode string,
corsAllowOrigins []string,
metricsToken string,
logger *zap.Logger,
) *Server {
engine := gin.New()
metrics := newRequestMetrics()
engine.Use(gin.Recovery())
engine.Use(requestMetricsMiddleware(metrics))
engine.Use(requestLogMiddleware(logger))
engine.Use(requestIDMiddleware())
engine.Use(cors.New(cors.Config{
AllowOrigins: corsAllowOrigins,
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodOptions},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "Cookie"},
ExposeHeaders: []string{"Content-Length", "X-Request-Id"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
server := &Server{
engine: engine,
mode: mode,
store: dataStore,
authClient: authClient,
mail: mailService,
files: fileStorage,
metrics: metrics,
metricsToken: metricsToken,
config: OAuthConfig{
GoogleClientID: getEnvOrDefault("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnvOrDefault("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURI: getEnvOrDefault("GOOGLE_REDIRECT_URI", "http://localhost:8080/v1/oauth/google-calendar/callback"),
SlackClientID: getEnvOrDefault("SLACK_CLIENT_ID", ""),
SlackClientSecret: getEnvOrDefault("SLACK_CLIENT_SECRET", ""),
SlackRedirectURI: getEnvOrDefault("SLACK_REDIRECT_URI", "http://localhost:8080/v1/oauth/slack/callback"),
},
}
server.registerRoutes()
return server
}
func (s *Server) Engine() *gin.Engine {
return s.engine
}
@@ -0,0 +1,173 @@
package httpapi
import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"productier/apps/backend/internal/filestorage"
"productier/apps/backend/internal/store"
)
const maxTaskAttachmentBytes int64 = 20 << 20 // 20 MB
func (s *Server) registerTaskAttachmentRoutes(group *gin.RouterGroup) {
group.POST("/tasks/:taskId/attachments", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
file, err := c.FormFile("file")
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, "file is required")
return
}
if file.Size <= 0 {
s.writeStatusError(c, http.StatusBadRequest, "file is empty")
return
}
if file.Size > maxTaskAttachmentBytes {
s.writeStatusError(c, http.StatusBadRequest, "file exceeds 20MB limit")
return
}
attachmentID := uuid.NewString()
objectKey := taskAttachmentObjectKey(task.ID, attachmentID)
src, err := file.Open()
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, "unable to read uploaded file")
return
}
defer src.Close()
mimeType := file.Header.Get("Content-Type")
if strings.TrimSpace(mimeType) == "" {
mimeType = "application/octet-stream"
}
if err := s.files.Put(c.Request.Context(), objectKey, src, mimeType, file.Size); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, "failed to store uploaded file")
return
}
attachment := store.Attachment{
ID: attachmentID,
Name: cleanAttachmentName(file.Filename),
MimeType: mimeType,
Size: int(file.Size),
DataURL: fmt.Sprintf("/v1/tasks/%s/attachments/%s/download", task.ID, attachmentID),
}
attachments := append([]store.Attachment{attachment}, task.Attachments...)
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: attachments}); err != nil {
_ = s.files.Delete(c.Request.Context(), objectKey)
s.writeStatusError(c, http.StatusInternalServerError, "failed to save task attachment")
return
}
c.JSON(http.StatusCreated, gin.H{"data": attachment})
})
group.GET("/tasks/:taskId/attachments/:attachmentId/download", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
attachmentID := c.Param("attachmentId")
var attachment *store.Attachment
for index := range task.Attachments {
if task.Attachments[index].ID == attachmentID {
attachment = &task.Attachments[index]
break
}
}
if attachment == nil {
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
return
}
object, err := s.files.Get(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID))
if err != nil {
if err == filestorage.ErrNotFound {
s.writeStatusError(c, http.StatusNotFound, "attachment file not found")
return
}
s.writeStatusError(c, http.StatusInternalServerError, "failed to read attachment file")
return
}
defer object.Body.Close()
c.Header("Content-Type", firstNonBlank(object.ContentType, attachment.MimeType, "application/octet-stream"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", cleanAttachmentName(attachment.Name)))
c.Status(http.StatusOK)
if _, err := io.Copy(c.Writer, object.Body); err != nil {
c.Status(http.StatusInternalServerError)
return
}
})
group.DELETE("/tasks/:taskId/attachments/:attachmentId", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
attachmentID := c.Param("attachmentId")
index := -1
for i := range task.Attachments {
if task.Attachments[i].ID == attachmentID {
index = i
break
}
}
if index < 0 {
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
return
}
nextAttachments := make([]store.Attachment, 0, len(task.Attachments)-1)
nextAttachments = append(nextAttachments, task.Attachments[:index]...)
nextAttachments = append(nextAttachments, task.Attachments[index+1:]...)
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: nextAttachments}); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, "failed to update task attachments")
return
}
if err := s.files.Delete(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID)); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, "attachment metadata updated but file cleanup failed")
return
}
c.Status(http.StatusNoContent)
})
}
func taskAttachmentObjectKey(taskID string, attachmentID string) string {
return fmt.Sprintf("tasks/%s/%s", taskID, attachmentID)
}
func cleanAttachmentName(name string) string {
cleaned := strings.TrimSpace(filepath.Base(name))
if cleaned == "" || cleaned == "." || cleaned == "/" {
return "attachment"
}
return cleaned
}
@@ -0,0 +1,876 @@
package mailruntime
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
stdmail "net/mail"
"net/smtp"
"regexp"
"sort"
"strings"
"time"
"github.com/emersion/go-imap"
imapclient "github.com/emersion/go-imap/client"
"github.com/emersion/go-message"
gomail "github.com/emersion/go-message/mail"
"go.uber.org/zap"
"productier/apps/backend/internal/store"
)
var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
type Service struct {
store store.Store
logger *zap.Logger
aead cipher.AEAD
}
type ConnectMailboxInput struct {
WorkspaceSlug string
Label string
Email string
DisplayName string
IMAPHost string
IMAPPort int
IMAPUsername string
IMAPPassword string
IMAPUseTLS bool
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPUseTLS bool
}
type QueueOutgoingMailInput struct {
WorkspaceSlug string
MailboxID string
To []store.MailAddress
Cc []store.MailAddress
Bcc []store.MailAddress
Subject string
TextBody string
HTMLBody string
ScheduledFor *time.Time
}
func New(dataStore store.Store, logger *zap.Logger, secretSeed string) (*Service, error) {
if logger == nil {
logger = zap.NewNop()
}
aead, err := newAEAD(secretSeed)
if err != nil {
return nil, err
}
return &Service{
store: dataStore,
logger: logger,
aead: aead,
}, nil
}
func (s *Service) Start(ctx context.Context) {
go s.runDueOutgoingLoop(ctx)
go s.runMailboxSyncLoop(ctx)
}
func (s *Service) ConnectMailbox(ctx context.Context, input ConnectMailboxInput) (store.Mailbox, error) {
input.normalize()
if err := input.validate(); err != nil {
return store.Mailbox{}, err
}
if err := s.verifyIMAP(ctx, input); err != nil {
return store.Mailbox{}, fmt.Errorf("verify imap connection: %w", err)
}
if err := s.verifySMTP(input); err != nil {
return store.Mailbox{}, fmt.Errorf("verify smtp connection: %w", err)
}
imapCiphertext, err := s.encrypt(input.IMAPPassword)
if err != nil {
return store.Mailbox{}, err
}
smtpCiphertext, err := s.encrypt(input.SMTPPassword)
if err != nil {
return store.Mailbox{}, err
}
mailbox, err := s.store.CreateMailbox(store.CreateMailboxRecordInput{
WorkspaceSlug: input.WorkspaceSlug,
Label: input.Label,
Email: input.Email,
DisplayName: input.DisplayName,
IMAPHost: input.IMAPHost,
IMAPPort: input.IMAPPort,
IMAPUsername: input.IMAPUsername,
IMAPPasswordCiphertext: imapCiphertext,
IMAPUseTLS: input.IMAPUseTLS,
SMTPHost: input.SMTPHost,
SMTPPort: input.SMTPPort,
SMTPUsername: input.SMTPUsername,
SMTPPasswordCiphertext: smtpCiphertext,
SMTPUseTLS: input.SMTPUseTLS,
})
if err != nil {
return store.Mailbox{}, err
}
if syncErr := s.SyncMailbox(ctx, mailbox.ID); syncErr != nil {
s.logger.Warn("initial mailbox sync failed", zap.String("mailboxId", mailbox.ID), zap.Error(syncErr))
}
return mailbox, nil
}
func (s *Service) QueueOutgoingMail(ctx context.Context, input QueueOutgoingMailInput) (store.OutgoingMail, error) {
if input.WorkspaceSlug == "" || input.MailboxID == "" {
return store.OutgoingMail{}, errors.New("workspace and mailbox are required")
}
if len(input.To) == 0 {
return store.OutgoingMail{}, errors.New("at least one recipient is required")
}
status := "queued"
if input.ScheduledFor != nil && input.ScheduledFor.After(time.Now().UTC()) {
status = "scheduled"
}
item, err := s.store.CreateOutgoingMail(store.CreateOutgoingMailInput{
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: input.MailboxID,
To: input.To,
Cc: input.Cc,
Bcc: input.Bcc,
Subject: input.Subject,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
Status: status,
ScheduledFor: input.ScheduledFor,
})
if err != nil {
return store.OutgoingMail{}, err
}
if status == "queued" {
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
updated, getErr := s.store.GetOutgoingMailByID(item.ID)
if getErr == nil {
return updated, err
}
return item, err
}
return s.store.GetOutgoingMailByID(item.ID)
}
return item, nil
}
func (s *Service) SendOutgoingMail(ctx context.Context, outgoingMailID string) error {
item, err := s.store.GetOutgoingMailByID(outgoingMailID)
if err != nil {
return err
}
connection, err := s.mailboxConnection(item.MailboxID)
if err != nil {
return err
}
if err := sendSMTPMessage(connection, connection.SMTPPasswordCiphertext, item); err != nil {
message := err.Error()
_, _ = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
Status: "failed",
Error: &message,
})
return err
}
now := time.Now().UTC()
empty := ""
_, err = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
Status: "sent",
SentAt: &now,
Error: &empty,
})
return err
}
func (s *Service) SyncMailbox(ctx context.Context, mailboxID string) error {
if _, err := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{SyncStatus: "syncing"}); err != nil {
return err
}
connection, err := s.mailboxConnection(mailboxID)
if err != nil {
s.markMailboxError(mailboxID, err)
return err
}
messages, err := fetchInboxMessages(connection, connection.IMAPPasswordCiphertext)
if err != nil {
s.markMailboxError(mailboxID, err)
return err
}
if err := s.store.UpsertMailMessages(mailboxID, messages); err != nil {
s.markMailboxError(mailboxID, err)
return err
}
now := time.Now().UTC()
empty := ""
_, err = s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
SyncStatus: "ready",
SyncError: &empty,
LastSyncedAt: &now,
})
return err
}
func (s *Service) runDueOutgoingLoop(ctx context.Context) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
due := s.store.ListDueOutgoingMails(time.Now().UTC(), 20)
for _, item := range due {
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
s.logger.Warn("send outgoing mail", zap.String("outgoingMailId", item.ID), zap.Error(err))
}
}
}
}
}
func (s *Service) runMailboxSyncLoop(ctx context.Context) {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, mailbox := range s.store.ListAllMailboxes() {
if err := s.SyncMailbox(ctx, mailbox.ID); err != nil {
s.logger.Warn("sync mailbox", zap.String("mailboxId", mailbox.ID), zap.Error(err))
}
}
}
}
}
func (s *Service) markMailboxError(mailboxID string, err error) {
message := err.Error()
_, updateErr := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
SyncStatus: "error",
SyncError: &message,
})
if updateErr != nil {
s.logger.Warn("update mailbox sync error", zap.String("mailboxId", mailboxID), zap.Error(updateErr))
}
}
func (s *Service) mailboxConnection(mailboxID string) (store.MailboxConnection, error) {
connection, err := s.store.GetMailboxConnection(mailboxID)
if err != nil {
return store.MailboxConnection{}, err
}
imapPassword, err := s.decrypt(connection.IMAPPasswordCiphertext)
if err != nil {
return store.MailboxConnection{}, err
}
smtpPassword, err := s.decrypt(connection.SMTPPasswordCiphertext)
if err != nil {
return store.MailboxConnection{}, err
}
connection.IMAPPasswordCiphertext = imapPassword
connection.SMTPPasswordCiphertext = smtpPassword
return connection, nil
}
func newAEAD(secretSeed string) (cipher.AEAD, error) {
if secretSeed == "" {
secretSeed = "productier-local-mail-key"
}
key, err := decodeSecretSeed(secretSeed)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func decodeSecretSeed(secretSeed string) ([]byte, error) {
if raw, err := base64.StdEncoding.DecodeString(secretSeed); err == nil && len(raw) == 32 {
return raw, nil
}
sum := sha256.Sum256([]byte(secretSeed))
return sum[:], nil
}
func (s *Service) encrypt(plain string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := s.aead.Seal(nonce, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
func (s *Service) decrypt(ciphertext string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
if len(raw) < s.aead.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce := raw[:s.aead.NonceSize()]
payload := raw[s.aead.NonceSize():]
plain, err := s.aead.Open(nil, nonce, payload, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func (input *ConnectMailboxInput) normalize() {
input.WorkspaceSlug = strings.TrimSpace(input.WorkspaceSlug)
input.Label = strings.TrimSpace(input.Label)
input.Email = strings.TrimSpace(strings.ToLower(input.Email))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.IMAPHost = strings.TrimSpace(input.IMAPHost)
input.SMTPHost = strings.TrimSpace(input.SMTPHost)
if input.IMAPPort == 0 {
input.IMAPPort = 993
}
if input.SMTPPort == 0 {
input.SMTPPort = 587
}
if input.IMAPUsername == "" {
input.IMAPUsername = input.Email
}
if input.SMTPUsername == "" {
input.SMTPUsername = input.IMAPUsername
}
if input.SMTPPassword == "" {
input.SMTPPassword = input.IMAPPassword
}
if input.Label == "" {
input.Label = input.Email
}
}
func (input ConnectMailboxInput) validate() error {
if input.WorkspaceSlug == "" || input.Email == "" {
return errors.New("workspace and email are required")
}
if input.IMAPHost == "" || input.SMTPHost == "" {
return errors.New("imap and smtp hosts are required")
}
if input.IMAPUsername == "" || input.IMAPPassword == "" {
return errors.New("imap credentials are required")
}
if input.SMTPUsername == "" || input.SMTPPassword == "" {
return errors.New("smtp credentials are required")
}
return nil
}
func (s *Service) verifyIMAP(ctx context.Context, input ConnectMailboxInput) error {
client, err := dialAndLoginIMAP(input.IMAPHost, input.IMAPPort, input.IMAPUseTLS, input.IMAPUsername, input.IMAPPassword)
if err != nil {
return err
}
defer client.Logout()
select {
case <-ctx.Done():
return ctx.Err()
default:
}
_, err = client.Select("INBOX", true)
return err
}
func (s *Service) verifySMTP(input ConnectMailboxInput) error {
client, err := dialSMTPClient(input.SMTPHost, input.SMTPPort, input.SMTPUseTLS)
if err != nil {
return err
}
defer func() {
_ = client.Quit()
_ = client.Close()
}()
return smtpAuthenticate(client, input.SMTPHost, input.SMTPUsername, input.SMTPPassword)
}
func fetchInboxMessages(connection store.MailboxConnection, password string) ([]store.InboundMailMessage, error) {
client, err := dialAndLoginIMAP(connection.IMAPHost, connection.IMAPPort, connection.IMAPUseTLS, connection.IMAPUsername, password)
if err != nil {
return nil, err
}
defer client.Logout()
mbox, err := client.Select("INBOX", true)
if err != nil {
return nil, err
}
if mbox.Messages == 0 {
return []store.InboundMailMessage{}, nil
}
uids, err := client.UidSearch(&imap.SearchCriteria{})
if err != nil {
return nil, err
}
if len(uids) == 0 {
return []store.InboundMailMessage{}, nil
}
sort.Slice(uids, func(i int, j int) bool { return uids[i] < uids[j] })
if len(uids) > 50 {
uids = uids[len(uids)-50:]
}
seqset := new(imap.SeqSet)
seqset.AddNum(uids...)
section := &imap.BodySectionName{}
items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags, imap.FetchEnvelope, section.FetchItem()}
ch := make(chan *imap.Message, len(uids))
done := make(chan error, 1)
go func() {
done <- client.UidFetch(seqset, items, ch)
}()
result := make([]store.InboundMailMessage, 0, len(uids))
for msg := range ch {
parsed, err := parseIMAPMessage(connection.WorkspaceSlug, connection.ID, section, msg)
if err != nil {
continue
}
result = append(result, parsed)
}
if err := <-done; err != nil {
return nil, err
}
sort.Slice(result, func(i int, j int) bool { return result[i].ReceivedAt.After(result[j].ReceivedAt) })
return result, nil
}
func parseIMAPMessage(workspaceSlug string, mailboxID string, section *imap.BodySectionName, msg *imap.Message) (store.InboundMailMessage, error) {
if msg == nil {
return store.InboundMailMessage{}, errors.New("nil message")
}
receivedAt := time.Now().UTC()
if msg.Envelope != nil && !msg.Envelope.Date.IsZero() {
receivedAt = msg.Envelope.Date.UTC()
}
parsed := store.InboundMailMessage{
WorkspaceSlug: workspaceSlug,
MailboxID: mailboxID,
RemoteUID: int64(msg.Uid),
Folder: "INBOX",
ReceivedAt: receivedAt,
IsRead: hasFlag(msg.Flags, imap.SeenFlag),
From: addressFromEnvelope(msg.Envelope),
To: addressesFromEnvelope(msg.Envelope, "to"),
Cc: addressesFromEnvelope(msg.Envelope, "cc"),
}
if msg.Envelope != nil {
parsed.Subject = msg.Envelope.Subject
}
body := msg.GetBody(section)
if body == nil {
parsed.Snippet = truncatePlaintext(parsed.Subject, 180)
return parsed, nil
}
reader, err := gomail.CreateReader(body)
if err != nil && !message.IsUnknownCharset(err) {
return store.InboundMailMessage{}, err
}
if headerMessageID, headerErr := reader.Header.MessageID(); headerErr == nil {
parsed.MessageID = headerMessageID
}
if subject, headerErr := reader.Header.Subject(); headerErr == nil && subject != "" {
parsed.Subject = subject
}
if from, headerErr := reader.Header.AddressList("From"); headerErr == nil && len(from) > 0 {
parsed.From = mailAddress(from[0])
}
if to, headerErr := reader.Header.AddressList("To"); headerErr == nil {
parsed.To = toStoreAddresses(to)
}
if cc, headerErr := reader.Header.AddressList("Cc"); headerErr == nil {
parsed.Cc = toStoreAddresses(cc)
}
if date, headerErr := reader.Header.Date(); headerErr == nil && !date.IsZero() {
parsed.ReceivedAt = date.UTC()
}
for {
part, partErr := reader.NextPart()
if errors.Is(partErr, io.EOF) {
break
}
if partErr != nil && !message.IsUnknownCharset(partErr) {
return store.InboundMailMessage{}, partErr
}
if part == nil {
break
}
contentType := ""
switch header := part.Header.(type) {
case *gomail.InlineHeader:
contentType = header.Get("Content-Type")
default:
contentType = part.Header.Get("Content-Type")
}
payload, readErr := io.ReadAll(io.LimitReader(part.Body, 1<<20))
if readErr != nil {
return store.InboundMailMessage{}, readErr
}
switch {
case strings.HasPrefix(strings.ToLower(contentType), "text/plain"):
if parsed.TextBody == "" {
parsed.TextBody = string(payload)
}
case strings.HasPrefix(strings.ToLower(contentType), "text/html"):
if parsed.HTMLBody == "" {
parsed.HTMLBody = string(payload)
}
}
}
if parsed.TextBody == "" && parsed.HTMLBody != "" {
parsed.TextBody = htmlToText(parsed.HTMLBody)
}
parsed.Snippet = truncatePlaintext(firstNonEmpty(parsed.TextBody, parsed.Subject), 240)
return parsed, nil
}
func sendSMTPMessage(connection store.MailboxConnection, password string, outgoing store.OutgoingMail) error {
messageBytes, err := buildOutgoingMessage(connection, outgoing)
if err != nil {
return err
}
client, err := dialSMTPClient(connection.SMTPHost, connection.SMTPPort, connection.SMTPUseTLS)
if err != nil {
return err
}
defer func() {
_ = client.Quit()
_ = client.Close()
}()
if err := smtpAuthenticate(client, connection.SMTPHost, connection.SMTPUsername, password); err != nil {
return err
}
if err := client.Mail(connection.Email); err != nil {
return err
}
for _, recipient := range uniqueRecipients(outgoing) {
if err := client.Rcpt(recipient); err != nil {
return err
}
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write(messageBytes); err != nil {
_ = writer.Close()
return err
}
return writer.Close()
}
func buildOutgoingMessage(connection store.MailboxConnection, outgoing store.OutgoingMail) ([]byte, error) {
var (
header gomail.Header
buffer bytes.Buffer
)
fromName := strings.TrimSpace(connection.DisplayName)
header.SetAddressList("From", []*gomail.Address{{Name: fromName, Address: connection.Email}})
header.SetAddressList("To", toMailAddresses(outgoing.To))
header.SetAddressList("Cc", toMailAddresses(outgoing.Cc))
header.SetAddressList("Bcc", toMailAddresses(outgoing.Bcc))
header.SetDate(time.Now().UTC())
header.SetSubject(firstNonEmpty(outgoing.Subject, "(no subject)"))
_ = header.GenerateMessageIDWithHostname(sanitizeHostname(connection.SMTPHost))
switch {
case outgoing.TextBody != "" && outgoing.HTMLBody != "":
writer, err := gomail.CreateInlineWriter(&buffer, header)
if err != nil {
return nil, err
}
var textHeader gomail.InlineHeader
textHeader.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
textPart, err := writer.CreatePart(textHeader)
if err != nil {
return nil, err
}
if _, err := io.WriteString(textPart, outgoing.TextBody); err != nil {
return nil, err
}
if err := textPart.Close(); err != nil {
return nil, err
}
var htmlHeader gomail.InlineHeader
htmlHeader.SetContentType("text/html", map[string]string{"charset": "utf-8"})
htmlPart, err := writer.CreatePart(htmlHeader)
if err != nil {
return nil, err
}
if _, err := io.WriteString(htmlPart, outgoing.HTMLBody); err != nil {
return nil, err
}
if err := htmlPart.Close(); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
case outgoing.HTMLBody != "":
header.SetContentType("text/html", map[string]string{"charset": "utf-8"})
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
if err != nil {
return nil, err
}
if _, err := io.WriteString(writer, outgoing.HTMLBody); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
default:
header.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
if err != nil {
return nil, err
}
if _, err := io.WriteString(writer, outgoing.TextBody); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
}
return buffer.Bytes(), nil
}
func dialAndLoginIMAP(host string, port int, useTLS bool, username string, password string) (*imapclient.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
var (
client *imapclient.Client
err error
)
if useTLS {
client, err = imapclient.DialTLS(addr, &tls.Config{ServerName: host})
} else {
client, err = imapclient.Dial(addr)
}
if err != nil {
return nil, err
}
if err := client.Login(username, password); err != nil {
_ = client.Logout()
return nil, err
}
return client, nil
}
func dialSMTPClient(host string, port int, useTLS bool) (*smtp.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
if useTLS && port == 465 {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return nil, err
}
return smtp.NewClient(conn, host)
}
client, err := smtp.Dial(addr)
if err != nil {
return nil, err
}
if useTLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil {
_ = client.Close()
return nil, err
}
}
}
return client, nil
}
func smtpAuthenticate(client *smtp.Client, host string, username string, password string) error {
if username == "" || password == "" {
return nil
}
if ok, _ := client.Extension("AUTH"); !ok {
return nil
}
return client.Auth(smtp.PlainAuth("", username, password, host))
}
func hasFlag(flags []string, target string) bool {
for _, flag := range flags {
if flag == target {
return true
}
}
return false
}
func addressFromEnvelope(envelope *imap.Envelope) store.MailAddress {
if envelope == nil || len(envelope.From) == 0 {
return store.MailAddress{}
}
address := envelope.From[0]
return store.MailAddress{
Name: address.PersonalName,
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
}
}
func addressesFromEnvelope(envelope *imap.Envelope, field string) []store.MailAddress {
if envelope == nil {
return []store.MailAddress{}
}
var source []*imap.Address
switch field {
case "to":
source = envelope.To
case "cc":
source = envelope.Cc
}
items := make([]store.MailAddress, 0, len(source))
for _, address := range source {
items = append(items, store.MailAddress{
Name: address.PersonalName,
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
})
}
return items
}
func toStoreAddresses(addrs []*gomail.Address) []store.MailAddress {
items := make([]store.MailAddress, 0, len(addrs))
for _, addr := range addrs {
items = append(items, store.MailAddress{Name: addr.Name, Email: addr.Address})
}
return items
}
func toMailAddresses(addrs []store.MailAddress) []*gomail.Address {
items := make([]*gomail.Address, 0, len(addrs))
for _, addr := range addrs {
if addr.Email == "" {
continue
}
items = append(items, &gomail.Address{Name: addr.Name, Address: addr.Email})
}
return items
}
func uniqueRecipients(outgoing store.OutgoingMail) []string {
set := make(map[string]struct{})
items := make([]string, 0, len(outgoing.To)+len(outgoing.Cc)+len(outgoing.Bcc))
for _, group := range [][]store.MailAddress{outgoing.To, outgoing.Cc, outgoing.Bcc} {
for _, addr := range group {
email := strings.TrimSpace(strings.ToLower(addr.Email))
if email == "" {
continue
}
if _, exists := set[email]; exists {
continue
}
set[email] = struct{}{}
items = append(items, email)
}
}
return items
}
func htmlToText(value string) string {
return strings.TrimSpace(htmlTagPattern.ReplaceAllString(value, " "))
}
func truncatePlaintext(value string, limit int) string {
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if len(value) <= limit {
return value
}
return strings.TrimSpace(value[:limit]) + "…"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func sanitizeHostname(host string) string {
parsedHost := strings.TrimSpace(host)
if parsedHost == "" {
return "localhost"
}
if strings.Contains(parsedHost, ":") {
if h, _, err := strings.Cut(parsedHost, ":"); err && h != "" {
return h
}
}
return parsedHost
}
func mailAddress(addr *stdmail.Address) store.MailAddress {
if addr == nil {
return store.MailAddress{}
}
return store.MailAddress{Name: addr.Name, Email: addr.Address}
}
@@ -0,0 +1,60 @@
package mailruntime
import (
"strings"
"testing"
"productier/apps/backend/internal/store"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
service, err := New(store.NewSeededState("test"), nil, "mailruntime-test-secret")
if err != nil {
t.Fatalf("New() error = %v", err)
}
ciphertext, err := service.encrypt("super-secret-password")
if err != nil {
t.Fatalf("encrypt() error = %v", err)
}
plain, err := service.decrypt(ciphertext)
if err != nil {
t.Fatalf("decrypt() error = %v", err)
}
if plain != "super-secret-password" {
t.Fatalf("decrypt() = %q, want %q", plain, "super-secret-password")
}
}
func TestBuildOutgoingMessageIncludesHeaders(t *testing.T) {
messageBytes, err := buildOutgoingMessage(store.MailboxConnection{
Mailbox: store.Mailbox{
Email: "sender@example.com",
DisplayName: "Sender",
SMTPHost: "smtp.example.com",
},
}, store.OutgoingMail{
To: []store.MailAddress{
{Name: "Recipient", Email: "recipient@example.com"},
},
Subject: "Quarterly Update",
TextBody: "Plain body",
})
if err != nil {
t.Fatalf("buildOutgoingMessage() error = %v", err)
}
message := string(messageBytes)
for _, fragment := range []string{
"Subject: Quarterly Update",
`From: "Sender" <sender@example.com>`,
`To: "Recipient" <recipient@example.com>`,
"Plain body",
} {
if !strings.Contains(message, fragment) {
t.Fatalf("built message missing %q\n%s", fragment, message)
}
}
}
+166
View File
@@ -0,0 +1,166 @@
package store
import "time"
// Contact represents a person in the CRM
type Contact struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
Phone string `json:"phone"`
CompanyID *string `json:"companyId,omitempty"`
CompanyName string `json:"companyName,omitempty"`
Title string `json:"title"`
Notes string `json:"notes"`
AvatarURL string `json:"avatarUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Company represents an organization in the CRM
type Company struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Name string `json:"name"`
Domain string `json:"domain"`
Website string `json:"website"`
Industry string `json:"industry"`
Size string `json:"size"`
Notes string `json:"notes"`
LogoURL string `json:"logoUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ContactTaskLink links a contact to a task
type ContactTaskLink struct {
ID string `json:"id"`
ContactID string `json:"contactId"`
TaskID string `json:"taskId"`
CreatedAt time.Time `json:"createdAt"`
}
// ContactEventLink links a contact to an event
type ContactEventLink struct {
ID string `json:"id"`
ContactID string `json:"contactId"`
EventID string `json:"eventId"`
CreatedAt time.Time `json:"createdAt"`
}
// InboxItem represents a quick capture item
type InboxItem struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Content string `json:"content"`
Source string `json:"source"`
Processed bool `json:"processed"`
ProcessedAt *time.Time `json:"processedAt,omitempty"`
ProcessedEntityType *string `json:"processedEntityType,omitempty"`
ProcessedEntityID *string `json:"processedEntityId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// TimeEntry represents logged time
type TimeEntry struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
TaskID *string `json:"taskId,omitempty"`
Description string `json:"description"`
StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
DurationSeconds int `json:"durationSeconds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// SavedView represents a user's saved filter/view
type SavedView struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Name string `json:"name"`
EntityType string `json:"entityType"`
FilterJSON string `json:"filterJson"`
SortJSON string `json:"sortJson"`
IsDefault bool `json:"isDefault"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// CreateContactInput for creating contacts
type CreateContactInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
Phone string `json:"phone"`
CompanyID *string `json:"companyId"`
Title string `json:"title"`
Notes string `json:"notes"`
}
// UpdateContactInput for updating contacts
type UpdateContactInput struct {
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
Email *string `json:"email"`
Phone *string `json:"phone"`
CompanyID *string `json:"companyId"`
Title *string `json:"title"`
Notes *string `json:"notes"`
}
// CreateCompanyInput for creating companies
type CreateCompanyInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Name string `json:"name" binding:"required"`
Domain string `json:"domain"`
Website string `json:"website"`
Industry string `json:"industry"`
Size string `json:"size"`
Notes string `json:"notes"`
}
// UpdateCompanyInput for updating companies
type UpdateCompanyInput struct {
Name *string `json:"name"`
Domain *string `json:"domain"`
Website *string `json:"website"`
Industry *string `json:"industry"`
Size *string `json:"size"`
Notes *string `json:"notes"`
}
// CreateInboxItemInput for quick capture
type CreateInboxItemInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Content string `json:"content" binding:"required"`
Source string `json:"source"`
}
// CreateTimeEntryInput for time tracking
type CreateTimeEntryInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
TaskID *string `json:"taskId"`
Description string `json:"description"`
StartedAt time.Time `json:"startedAt" binding:"required"`
EndedAt *time.Time `json:"endedAt"`
}
// UpdateTimeEntryInput for updating time entries
type UpdateTimeEntryInput struct {
Description *string `json:"description"`
EndedAt *time.Time `json:"endedAt"`
}
// CreateSavedViewInput for saved views
type CreateSavedViewInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Name string `json:"name" binding:"required"`
EntityType string `json:"entityType" binding:"required"`
FilterJSON string `json:"filterJson"`
SortJSON string `json:"sortJson"`
IsDefault bool `json:"isDefault"`
}
+568
View File
@@ -0,0 +1,568 @@
package store
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
// Contact methods
func (s *PostgresStore) ListContacts(workspaceSlug string) []Contact {
rows, err := s.db.Query(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
LEFT JOIN companies co ON c.company_id = co.id
WHERE c.workspace_slug = $1
ORDER BY c.updated_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
var companyID sql.NullString
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
c.CompanyID = nullStringToPtr(companyID)
contacts = append(contacts, c)
}
return contacts
}
func (s *PostgresStore) GetContactByID(contactID string) (Contact, error) {
var c Contact
var companyID sql.NullString
err := s.db.QueryRow(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
LEFT JOIN companies co ON c.company_id = co.id
WHERE c.id = $1
`, contactID).Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt)
if err != nil {
return c, err
}
c.CompanyID = nullStringToPtr(companyID)
return c, nil
}
func (s *PostgresStore) CreateContact(input CreateContactInput) Contact {
now := time.Now().UTC()
c := Contact{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Phone: input.Phone,
CompanyID: input.CompanyID,
Title: input.Title,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO contacts (id, workspace_slug, first_name, last_name, email, phone,
company_id, title, notes, avatar_url, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, '', $10, $11)
`, c.ID, c.WorkspaceSlug, c.FirstName, c.LastName, c.Email, c.Phone,
ptrToNullString(c.CompanyID), c.Title, c.Notes, c.CreatedAt, c.UpdatedAt)
return c
}
func (s *PostgresStore) UpdateContact(contactID string, input UpdateContactInput) (Contact, error) {
c, err := s.GetContactByID(contactID)
if err != nil {
return c, err
}
if input.FirstName != nil {
c.FirstName = *input.FirstName
}
if input.LastName != nil {
c.LastName = *input.LastName
}
if input.Email != nil {
c.Email = *input.Email
}
if input.Phone != nil {
c.Phone = *input.Phone
}
if input.CompanyID != nil {
c.CompanyID = input.CompanyID
}
if input.Title != nil {
c.Title = *input.Title
}
if input.Notes != nil {
c.Notes = *input.Notes
}
c.UpdatedAt = time.Now().UTC()
s.db.Exec(`
UPDATE contacts SET first_name = $1, last_name = $2, email = $3, phone = $4,
company_id = $5, title = $6, notes = $7, updated_at = $8
WHERE id = $9
`, c.FirstName, c.LastName, c.Email, c.Phone, ptrToNullString(c.CompanyID),
c.Title, c.Notes, c.UpdatedAt, c.ID)
return c, nil
}
func (s *PostgresStore) DeleteContact(contactID string) error {
_, err := s.db.Exec(`DELETE FROM contacts WHERE id = $1`, contactID)
return err
}
// Company methods
func (s *PostgresStore) ListCompanies(workspaceSlug string) []Company {
rows, err := s.db.Query(`
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
FROM companies
WHERE workspace_slug = $1
ORDER BY name ASC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var companies []Company
for rows.Next() {
var c Company
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
companies = append(companies, c)
}
return companies
}
func (s *PostgresStore) GetCompanyByID(companyID string) (Company, error) {
var c Company
err := s.db.QueryRow(`
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
FROM companies
WHERE id = $1
`, companyID).Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt)
return c, err
}
func (s *PostgresStore) CreateCompany(input CreateCompanyInput) Company {
now := time.Now().UTC()
c := Company{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
Domain: input.Domain,
Website: input.Website,
Industry: input.Industry,
Size: input.Size,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO companies (id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10)
`, c.ID, c.WorkspaceSlug, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.CreatedAt, c.UpdatedAt)
return c
}
func (s *PostgresStore) UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error) {
c, err := s.GetCompanyByID(companyID)
if err != nil {
return c, err
}
if input.Name != nil {
c.Name = *input.Name
}
if input.Domain != nil {
c.Domain = *input.Domain
}
if input.Website != nil {
c.Website = *input.Website
}
if input.Industry != nil {
c.Industry = *input.Industry
}
if input.Size != nil {
c.Size = *input.Size
}
if input.Notes != nil {
c.Notes = *input.Notes
}
c.UpdatedAt = time.Now().UTC()
s.db.Exec(`
UPDATE companies SET name = $1, domain = $2, website = $3, industry = $4, size = $5, notes = $6, updated_at = $7
WHERE id = $8
`, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.UpdatedAt, c.ID)
return c, nil
}
func (s *PostgresStore) DeleteCompany(companyID string) error {
_, err := s.db.Exec(`DELETE FROM companies WHERE id = $1`, companyID)
return err
}
// Contact-Task linking
func (s *PostgresStore) LinkContactToTask(contactID, taskID string) error {
_, err := s.db.Exec(`
INSERT INTO contact_tasks (id, contact_id, task_id, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (contact_id, task_id) DO NOTHING
`, uuid.NewString(), contactID, taskID, time.Now().UTC())
return err
}
func (s *PostgresStore) UnlinkContactFromTask(contactID, taskID string) error {
_, err := s.db.Exec(`DELETE FROM contact_tasks WHERE contact_id = $1 AND task_id = $2`, contactID, taskID)
return err
}
func (s *PostgresStore) ListContactsForTask(taskID string) []Contact {
rows, err := s.db.Query(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
JOIN contact_tasks ct ON c.id = ct.contact_id
LEFT JOIN companies co ON c.company_id = co.id
WHERE ct.task_id = $1
`, taskID)
if err != nil {
return nil
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
var companyID sql.NullString
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
c.CompanyID = nullStringToPtr(companyID)
contacts = append(contacts, c)
}
return contacts
}
// Contact-Event linking
func (s *PostgresStore) LinkContactToEvent(contactID, eventID string) error {
_, err := s.db.Exec(`
INSERT INTO contact_events (id, contact_id, event_id, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (contact_id, event_id) DO NOTHING
`, uuid.NewString(), contactID, eventID, time.Now().UTC())
return err
}
func (s *PostgresStore) ListContactsForEvent(eventID string) []Contact {
rows, err := s.db.Query(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
JOIN contact_events ce ON c.id = ce.contact_id
LEFT JOIN companies co ON c.company_id = co.id
WHERE ce.event_id = $1
`, eventID)
if err != nil {
return nil
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
var companyID sql.NullString
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
c.CompanyID = nullStringToPtr(companyID)
contacts = append(contacts, c)
}
return contacts
}
// Helper functions
func nullStringToPtr(ns sql.NullString) *string {
if !ns.Valid {
return nil
}
return &ns.String
}
func ptrToNullString(s *string) sql.NullString {
if s == nil {
return sql.NullString{Valid: false}
}
return sql.NullString{String: *s, Valid: true}
}
// Inbox methods
func (s *PostgresStore) ListInboxItems(workspaceSlug string) []InboxItem {
rows, err := s.db.Query(`
SELECT id, workspace_slug, content, source, processed, processed_at,
processed_entity_type, processed_entity_id, created_at
FROM inbox_items
WHERE workspace_slug = $1 AND processed = false
ORDER BY created_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var items []InboxItem
for rows.Next() {
var item InboxItem
var processedAt sql.NullTime
var processedEntityType, processedEntityID sql.NullString
if err := rows.Scan(&item.ID, &item.WorkspaceSlug, &item.Content, &item.Source,
&item.Processed, &processedAt, &processedEntityType, &processedEntityID,
&item.CreatedAt); err != nil {
continue
}
item.ProcessedAt = nullTimeToPtr(processedAt)
item.ProcessedEntityType = nullStringToPtr(processedEntityType)
item.ProcessedEntityID = nullStringToPtr(processedEntityID)
items = append(items, item)
}
return items
}
func (s *PostgresStore) CreateInboxItem(input CreateInboxItemInput) InboxItem {
now := time.Now().UTC()
item := InboxItem{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Content: input.Content,
Source: input.Source,
CreatedAt: now,
}
if item.Source == "" {
item.Source = "manual"
}
s.db.Exec(`
INSERT INTO inbox_items (id, workspace_slug, content, source, processed, created_at)
VALUES ($1, $2, $3, $4, false, $5)
`, item.ID, item.WorkspaceSlug, item.Content, item.Source, item.CreatedAt)
return item
}
func (s *PostgresStore) ProcessInboxItem(itemID string, entityType, entityID string) error {
now := time.Now().UTC()
_, err := s.db.Exec(`
UPDATE inbox_items
SET processed = true, processed_at = $1, processed_entity_type = $2, processed_entity_id = $3
WHERE id = $4
`, now, entityType, entityID, itemID)
return err
}
func (s *PostgresStore) DeleteInboxItem(itemID string) error {
_, err := s.db.Exec(`DELETE FROM inbox_items WHERE id = $1`, itemID)
return err
}
// Time entry methods
func (s *PostgresStore) ListTimeEntries(workspaceSlug string) []TimeEntry {
rows, err := s.db.Query(`
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
FROM time_entries
WHERE workspace_slug = $1
ORDER BY started_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var entries []TimeEntry
for rows.Next() {
var e TimeEntry
var taskID sql.NullString
var endedAt sql.NullTime
if err := rows.Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description,
&e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt); err != nil {
continue
}
e.TaskID = nullStringToPtr(taskID)
e.EndedAt = nullTimeToPtr(endedAt)
entries = append(entries, e)
}
return entries
}
func (s *PostgresStore) CreateTimeEntry(input CreateTimeEntryInput) TimeEntry {
now := time.Now().UTC()
e := TimeEntry{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
TaskID: input.TaskID,
Description: input.Description,
StartedAt: input.StartedAt,
EndedAt: input.EndedAt,
DurationSeconds: 0,
CreatedAt: now,
UpdatedAt: now,
}
if input.EndedAt != nil {
e.DurationSeconds = int(input.EndedAt.Sub(input.StartedAt).Seconds())
}
s.db.Exec(`
INSERT INTO time_entries (id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, e.ID, e.WorkspaceSlug, ptrToNullString(e.TaskID), e.Description, e.StartedAt,
ptrToNullTime(e.EndedAt), e.DurationSeconds, e.CreatedAt, e.UpdatedAt)
return e
}
func (s *PostgresStore) UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error) {
var e TimeEntry
var taskID sql.NullString
var endedAt sql.NullTime
err := s.db.QueryRow(`
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
FROM time_entries WHERE id = $1
`, entryID).Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description, &e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt)
if err != nil {
return e, err
}
e.TaskID = nullStringToPtr(taskID)
e.EndedAt = nullTimeToPtr(endedAt)
if input.Description != nil {
e.Description = *input.Description
}
if input.EndedAt != nil {
e.EndedAt = input.EndedAt
e.DurationSeconds = int(input.EndedAt.Sub(e.StartedAt).Seconds())
}
e.UpdatedAt = time.Now().UTC()
s.db.Exec(`
UPDATE time_entries SET description = $1, ended_at = $2, duration_seconds = $3, updated_at = $4
WHERE id = $5
`, e.Description, ptrToNullTime(e.EndedAt), e.DurationSeconds, e.UpdatedAt, e.ID)
return e, nil
}
func (s *PostgresStore) DeleteTimeEntry(entryID string) error {
_, err := s.db.Exec(`DELETE FROM time_entries WHERE id = $1`, entryID)
return err
}
// Saved view methods
func (s *PostgresStore) ListSavedViews(workspaceSlug, entityType string) []SavedView {
rows, err := s.db.Query(`
SELECT id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at
FROM saved_views
WHERE workspace_slug = $1 AND entity_type = $2
ORDER BY name ASC
`, workspaceSlug, entityType)
if err != nil {
return nil
}
defer rows.Close()
var views []SavedView
for rows.Next() {
var v SavedView
if err := rows.Scan(&v.ID, &v.WorkspaceSlug, &v.Name, &v.EntityType,
&v.FilterJSON, &v.SortJSON, &v.IsDefault, &v.CreatedAt, &v.UpdatedAt); err != nil {
continue
}
views = append(views, v)
}
return views
}
func (s *PostgresStore) CreateSavedView(input CreateSavedViewInput) SavedView {
now := time.Now().UTC()
v := SavedView{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
EntityType: input.EntityType,
FilterJSON: input.FilterJSON,
SortJSON: input.SortJSON,
IsDefault: input.IsDefault,
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO saved_views (id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, v.ID, v.WorkspaceSlug, v.Name, v.EntityType, v.FilterJSON, v.SortJSON, v.IsDefault, v.CreatedAt, v.UpdatedAt)
return v
}
func (s *PostgresStore) DeleteSavedView(viewID string) error {
_, err := s.db.Exec(`DELETE FROM saved_views WHERE id = $1`, viewID)
return err
}
func nullTimeToPtr(nt sql.NullTime) *time.Time {
if !nt.Valid {
return nil
}
return &nt.Time
}
func ptrToNullTime(t *time.Time) sql.NullTime {
if t == nil {
return sql.NullTime{Valid: false}
}
return sql.NullTime{Time: *t, Valid: true}
}
// Ensure PostgresStore implements Store interface for new methods
var _ Store = (*PostgresStore)(nil)
// Add interface methods to store.go
// These will be added to the Store interface in store.go
@@ -0,0 +1,95 @@
package store
import (
"database/sql"
"time"
)
// Integration represents an external service connection
type Integration struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Provider string `json:"provider"`
Name string `json:"name"`
Config string `json:"config"`
Status string `json:"status"`
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Webhook represents an external webhook endpoint
type Webhook struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Name string `json:"name"`
URL string `json:"url"`
Events string `json:"events"` // JSON array
Active bool `json:"active"`
LastTriggeredAt *time.Time `json:"lastTriggeredAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Notification represents a user notification
type Notification struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
UserEmail string `json:"userEmail"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
EntityType *string `json:"entityType,omitempty"`
EntityID *string `json:"entityId,omitempty"`
Read bool `json:"read"`
CreatedAt time.Time `json:"createdAt"`
}
// Presence represents a user's real-time presence
type Presence struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
UserEmail string `json:"userEmail"`
UserName string `json:"userName"`
EntityType *string `json:"entityType,omitempty"`
EntityID *string `json:"entityId,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt"`
CreatedAt time.Time `json:"createdAt"`
}
// CreateIntegrationInput for creating integrations
type CreateIntegrationInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Provider string `json:"provider" binding:"required"`
Name string `json:"name" binding:"required"`
Config string `json:"config"`
Credentials string `json:"credentials" binding:"required"`
}
// CreateWebhookInput for creating webhooks
type CreateWebhookInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Events string `json:"events"` // JSON array
}
// CreateNotificationInput for creating notifications
type CreateNotificationInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
UserEmail string `json:"userEmail" binding:"required"`
Type string `json:"type" binding:"required"`
Title string `json:"title" binding:"required"`
Body string `json:"body"`
EntityType *string `json:"entityType"`
EntityID *string `json:"entityId"`
}
// UpdatePresenceInput for updating presence
type UpdatePresenceInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
UserEmail string `json:"userEmail" binding:"required"`
UserName string `json:"userName" binding:"required"`
EntityType *string `json:"entityType"`
EntityID *string `json:"entityId"`
}
@@ -0,0 +1,269 @@
package store
import (
"database/sql"
"time"
"github.com/google/uuid"
)
// Integration methods
func (s *PostgresStore) ListIntegrations(workspaceSlug string) []Integration {
rows, err := s.db.Query(`
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
FROM integrations
WHERE workspace_slug = $1 AND status = 'active'
ORDER BY created_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var integrations []Integration
for rows.Next() {
var i Integration
var lastSync sql.NullTime
if err := rows.Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt); err != nil {
continue
}
i.LastSyncAt = nullTimeToPtr(lastSync)
integrations = append(integrations, i)
}
return integrations
}
func (s *PostgresStore) GetIntegrationByID(integrationID string) (Integration, error) {
var i Integration
var lastSync sql.NullTime
err := s.db.QueryRow(`
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
FROM integrations WHERE id = $1
`, integrationID).Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt)
if err != nil {
return i, err
}
i.LastSyncAt = nullTimeToPtr(lastSync)
return i, nil
}
func (s *PostgresStore) CreateIntegration(input CreateIntegrationInput) Integration {
now := time.Now().UTC()
i := Integration{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Provider: input.Provider,
Name: input.Name,
Config: input.Config,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO integrations (id, workspace_slug, provider, name, config, credentials_ciphertext, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8)
`, i.ID, i.WorkspaceSlug, i.Provider, i.Name, i.Config, input.Credentials, i.CreatedAt, i.UpdatedAt)
return i
}
func (s *PostgresStore) DeleteIntegration(integrationID string) error {
_, err := s.db.Exec(`UPDATE integrations SET status = 'deleted', updated_at = $1 WHERE id = $2`, time.Now().UTC(), integrationID)
return err
}
// Webhook methods
func (s *PostgresStore) ListWebhooks(workspaceSlug string) []Webhook {
rows, err := s.db.Query(`
SELECT id, workspace_slug, name, url, events, active, last_triggered_at, created_at, updated_at
FROM webhooks
WHERE workspace_slug = $1
ORDER BY created_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var lastTriggered sql.NullTime
if err := rows.Scan(&w.ID, &w.WorkspaceSlug, &w.Name, &w.URL, &w.Events,
&w.Active, &lastTriggered, &w.CreatedAt, &w.UpdatedAt); err != nil {
continue
}
w.LastTriggeredAt = nullTimeToPtr(lastTriggered)
webhooks = append(webhooks, w)
}
return webhooks
}
func (s *PostgresStore) CreateWebhook(input CreateWebhookInput) Webhook {
now := time.Now().UTC()
w := Webhook{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
URL: input.URL,
Events: input.Events,
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if w.Events == "" {
w.Events = "[]"
}
s.db.Exec(`
INSERT INTO webhooks (id, workspace_slug, name, url, secret, events, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, w.ID, w.WorkspaceSlug, w.Name, w.URL, uuid.NewString(), w.Events, w.Active, w.CreatedAt, w.UpdatedAt)
return w
}
func (s *PostgresStore) DeleteWebhook(webhookID string) error {
_, err := s.db.Exec(`DELETE FROM webhooks WHERE id = $1`, webhookID)
return err
}
// Notification methods
func (s *PostgresStore) ListNotifications(userEmail string, limit int) []Notification {
if limit <= 0 {
limit = 50
}
rows, err := s.db.Query(`
SELECT id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at
FROM notifications
WHERE user_email = $1
ORDER BY created_at DESC
LIMIT $2
`, userEmail, limit)
if err != nil {
return nil
}
defer rows.Close()
var notifications []Notification
for rows.Next() {
var n Notification
var entityType, entityID sql.NullString
if err := rows.Scan(&n.ID, &n.WorkspaceSlug, &n.UserEmail, &n.Type, &n.Title,
&n.Body, &entityType, &entityID, &n.Read, &n.CreatedAt); err != nil {
continue
}
n.EntityType = nullStringToPtr(entityType)
n.EntityID = nullStringToPtr(entityID)
notifications = append(notifications, n)
}
return notifications
}
func (s *PostgresStore) CreateNotification(input CreateNotificationInput) Notification {
n := Notification{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
UserEmail: input.UserEmail,
Type: input.Type,
Title: input.Title,
Body: input.Body,
EntityType: input.EntityType,
EntityID: input.EntityID,
Read: false,
CreatedAt: time.Now().UTC(),
}
s.db.Exec(`
INSERT INTO notifications (id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9)
`, n.ID, n.WorkspaceSlug, n.UserEmail, n.Type, n.Title, n.Body,
ptrToNullString(n.EntityType), ptrToNullString(n.EntityID), n.CreatedAt)
return n
}
func (s *PostgresStore) MarkNotificationRead(notificationID string) error {
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE id = $1`, notificationID)
return err
}
func (s *PostgresStore) MarkAllNotificationsRead(userEmail string) error {
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE user_email = $1 AND read = false`, userEmail)
return err
}
func (s *PostgresStore) UnreadNotificationCount(userEmail string) int {
var count int
s.db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE user_email = $1 AND read = false`, userEmail).Scan(&count)
return count
}
// Presence methods
func (s *PostgresStore) UpdatePresence(input UpdatePresenceInput) Presence {
now := time.Now().UTC()
// Upsert presence
s.db.Exec(`
INSERT INTO presence (id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (workspace_slug, user_email) DO UPDATE SET
user_name = EXCLUDED.user_name,
entity_type = EXCLUDED.entity_type,
entity_id = EXCLUDED.entity_id,
last_seen_at = EXCLUDED.last_seen_at
`, uuid.NewString(), input.WorkspaceSlug, input.UserEmail, input.UserName,
ptrToNullString(input.EntityType), ptrToNullString(input.EntityID), now, now)
return Presence{
WorkspaceSlug: input.WorkspaceSlug,
UserEmail: input.UserEmail,
UserName: input.UserName,
EntityType: input.EntityType,
EntityID: input.EntityID,
LastSeenAt: now,
CreatedAt: now,
}
}
func (s *PostgresStore) ListPresence(workspaceSlug string, entityType, entityID string) []Presence {
rows, err := s.db.Query(`
SELECT id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at
FROM presence
WHERE workspace_slug = $1
AND last_seen_at > $2
AND ($3 = '' OR entity_type = $3)
AND ($4 = '' OR entity_id = $4)
ORDER BY last_seen_at DESC
`, workspaceSlug, time.Now().Add(-5*time.Minute), entityType, entityID)
if err != nil {
return nil
}
defer rows.Close()
var presences []Presence
for rows.Next() {
var p Presence
var entityType, entityID sql.NullString
if err := rows.Scan(&p.ID, &p.WorkspaceSlug, &p.UserEmail, &p.UserName,
&entityType, &entityID, &p.LastSeenAt, &p.CreatedAt); err != nil {
continue
}
p.EntityType = nullStringToPtr(entityType)
p.EntityID = nullStringToPtr(entityID)
presences = append(presences, p)
}
return presences
}
func (s *PostgresStore) ClearPresence(workspaceSlug, userEmail string) error {
_, err := s.db.Exec(`DELETE FROM presence WHERE workspace_slug = $1 AND user_email = $2`, workspaceSlug, userEmail)
return err
}
+453
View File
@@ -0,0 +1,453 @@
package store
import (
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
type Mailbox struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Label string `json:"label"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IMAPHost string `json:"imapHost"`
IMAPPort int `json:"imapPort"`
IMAPUsername string `json:"imapUsername"`
IMAPUseTLS bool `json:"imapUseTls"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPUseTLS bool `json:"smtpUseTls"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
SyncStatus string `json:"syncStatus"`
SyncError string `json:"syncError,omitempty"`
}
type MailAddress struct {
Name string `json:"name"`
Email string `json:"email"`
}
type MailMessage struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
MailboxID string `json:"mailboxId"`
RemoteUID int64 `json:"remoteUid"`
MessageID string `json:"messageId"`
Folder string `json:"folder"`
From MailAddress `json:"from"`
To []MailAddress `json:"to"`
Cc []MailAddress `json:"cc"`
Subject string `json:"subject"`
Snippet string `json:"snippet"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody"`
ReceivedAt time.Time `json:"receivedAt"`
IsRead bool `json:"isRead"`
LinkedTaskID *string `json:"linkedTaskId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type OutgoingMail struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
MailboxID string `json:"mailboxId"`
To []MailAddress `json:"to"`
Cc []MailAddress `json:"cc"`
Bcc []MailAddress `json:"bcc"`
Subject string `json:"subject"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody"`
Status string `json:"status"`
ScheduledFor *time.Time `json:"scheduledFor,omitempty"`
SentAt *time.Time `json:"sentAt,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type MailboxConnection struct {
Mailbox
IMAPPasswordCiphertext string
SMTPPasswordCiphertext string
}
type CreateMailboxRecordInput struct {
WorkspaceSlug string
Label string
Email string
DisplayName string
IMAPHost string
IMAPPort int
IMAPUsername string
IMAPPasswordCiphertext string
IMAPUseTLS bool
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPasswordCiphertext string
SMTPUseTLS bool
}
type UpdateMailboxSyncStatusInput struct {
SyncStatus string
SyncError *string
LastSyncedAt *time.Time
}
type InboundMailMessage struct {
WorkspaceSlug string
MailboxID string
RemoteUID int64
MessageID string
Folder string
From MailAddress
To []MailAddress
Cc []MailAddress
Subject string
Snippet string
TextBody string
HTMLBody string
ReceivedAt time.Time
IsRead bool
}
type CreateOutgoingMailInput struct {
WorkspaceSlug string
MailboxID string
To []MailAddress
Cc []MailAddress
Bcc []MailAddress
Subject string
TextBody string
HTMLBody string
Status string
ScheduledFor *time.Time
}
type UpdateOutgoingMailStatusInput struct {
Status string
SentAt *time.Time
Error *string
}
func (s *State) ListAllMailboxes() []Mailbox {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]Mailbox(nil), s.Mailboxes...)
}
func (s *State) ListMailboxes(workspaceSlug string) []Mailbox {
s.mu.RLock()
defer s.mu.RUnlock()
return filterByWorkspace(s.Mailboxes, workspaceSlug)
}
func (s *State) GetMailboxByID(mailboxID string) (Mailbox, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, mailbox := range s.Mailboxes {
if mailbox.ID == mailboxID {
return mailbox, nil
}
}
return Mailbox{}, errors.New("mailbox not found")
}
func (s *State) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
s.mu.RLock()
defer s.mu.RUnlock()
connection, ok := s.MailboxAuth[mailboxID]
if !ok {
return MailboxConnection{}, errors.New("mailbox connection not found")
}
return connection, nil
}
func (s *State) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
mailbox := Mailbox{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Label: input.Label,
Email: input.Email,
DisplayName: input.DisplayName,
IMAPHost: input.IMAPHost,
IMAPPort: input.IMAPPort,
IMAPUsername: input.IMAPUsername,
IMAPUseTLS: input.IMAPUseTLS,
SMTPHost: input.SMTPHost,
SMTPPort: input.SMTPPort,
SMTPUsername: input.SMTPUsername,
SMTPUseTLS: input.SMTPUseTLS,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "idle",
}
s.Mailboxes = append([]Mailbox{mailbox}, s.Mailboxes...)
s.MailboxAuth[mailbox.ID] = MailboxConnection{
Mailbox: mailbox,
IMAPPasswordCiphertext: input.IMAPPasswordCiphertext,
SMTPPasswordCiphertext: input.SMTPPasswordCiphertext,
}
s.appendActivityLocked(input.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", input.Email))
return mailbox, nil
}
func (s *State) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
s.mu.Lock()
defer s.mu.Unlock()
for index, mailbox := range s.Mailboxes {
if mailbox.ID != mailboxID {
continue
}
if input.SyncStatus != "" {
mailbox.SyncStatus = input.SyncStatus
}
if input.SyncError != nil {
mailbox.SyncError = *input.SyncError
}
if input.LastSyncedAt != nil {
mailbox.LastSyncedAt = input.LastSyncedAt
}
mailbox.UpdatedAt = time.Now().UTC()
s.Mailboxes[index] = mailbox
if connection, ok := s.MailboxAuth[mailboxID]; ok {
connection.Mailbox = mailbox
s.MailboxAuth[mailboxID] = connection
}
return mailbox, nil
}
return Mailbox{}, errors.New("mailbox not found")
}
func (s *State) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
s.mu.RLock()
defer s.mu.RUnlock()
items := filterByWorkspace(s.MailMessages, workspaceSlug)
if mailboxID == "" {
return items
}
filtered := make([]MailMessage, 0, len(items))
for _, item := range items {
if item.MailboxID == mailboxID {
filtered = append(filtered, item)
}
}
return filtered
}
func (s *State) GetMailMessageByID(messageID string) (MailMessage, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, message := range s.MailMessages {
if message.ID == messageID {
return message, nil
}
}
return MailMessage{}, errors.New("mail message not found")
}
func (s *State) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
for _, input := range messages {
found := false
for index, message := range s.MailMessages {
if message.MailboxID == mailboxID && message.Folder == input.Folder && message.RemoteUID == input.RemoteUID {
linkedTaskID := message.LinkedTaskID
s.MailMessages[index] = MailMessage{
ID: message.ID,
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: mailboxID,
RemoteUID: input.RemoteUID,
MessageID: input.MessageID,
Folder: input.Folder,
From: input.From,
To: input.To,
Cc: input.Cc,
Subject: input.Subject,
Snippet: input.Snippet,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
ReceivedAt: input.ReceivedAt,
IsRead: input.IsRead,
LinkedTaskID: linkedTaskID,
CreatedAt: message.CreatedAt,
UpdatedAt: now,
}
found = true
break
}
}
if found {
continue
}
s.MailMessages = append([]MailMessage{{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: mailboxID,
RemoteUID: input.RemoteUID,
MessageID: input.MessageID,
Folder: input.Folder,
From: input.From,
To: input.To,
Cc: input.Cc,
Subject: input.Subject,
Snippet: input.Snippet,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
ReceivedAt: input.ReceivedAt,
IsRead: input.IsRead,
CreatedAt: now,
UpdatedAt: now,
}}, s.MailMessages...)
}
return nil
}
func (s *State) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
for index, message := range s.MailMessages {
if message.ID != messageID {
continue
}
message.LinkedTaskID = &taskID
message.UpdatedAt = time.Now().UTC()
s.MailMessages[index] = message
return message, nil
}
return MailMessage{}, errors.New("mail message not found")
}
func (s *State) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
s.mu.RLock()
defer s.mu.RUnlock()
items := filterByWorkspace(s.OutgoingMails, workspaceSlug)
if mailboxID == "" {
return items
}
filtered := make([]OutgoingMail, 0, len(items))
for _, item := range items {
if item.MailboxID == mailboxID {
filtered = append(filtered, item)
}
}
return filtered
}
func (s *State) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]OutgoingMail, 0, limit)
for _, item := range s.OutgoingMails {
if item.Status != "queued" && item.Status != "scheduled" {
continue
}
if item.ScheduledFor != nil && item.ScheduledFor.After(now) {
continue
}
items = append(items, item)
if limit > 0 && len(items) >= limit {
break
}
}
return items
}
func (s *State) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, item := range s.OutgoingMails {
if item.ID == outgoingMailID {
return item, nil
}
}
return OutgoingMail{}, errors.New("outgoing mail not found")
}
func (s *State) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
item := OutgoingMail{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: input.MailboxID,
To: input.To,
Cc: input.Cc,
Bcc: input.Bcc,
Subject: input.Subject,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
Status: input.Status,
ScheduledFor: input.ScheduledFor,
CreatedAt: now,
UpdatedAt: now,
}
s.OutgoingMails = append([]OutgoingMail{item}, s.OutgoingMails...)
s.appendActivityLocked(input.WorkspaceSlug, "Outgoing mail queued", input.Subject)
return item, nil
}
func (s *State) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
s.mu.Lock()
defer s.mu.Unlock()
for index, item := range s.OutgoingMails {
if item.ID != outgoingMailID {
continue
}
if input.Status != "" {
item.Status = input.Status
}
if input.SentAt != nil {
item.SentAt = input.SentAt
}
if input.Error != nil {
item.Error = *input.Error
}
item.UpdatedAt = time.Now().UTC()
s.OutgoingMails[index] = item
return item, nil
}
return OutgoingMail{}, errors.New("outgoing mail not found")
}
func (item Mailbox) GetWorkspaceSlug() string { return item.WorkspaceSlug }
func (item MailMessage) GetWorkspaceSlug() string { return item.WorkspaceSlug }
func (item OutgoingMail) GetWorkspaceSlug() string { return item.WorkspaceSlug }
@@ -0,0 +1,550 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
func (s *PostgresStore) ListAllMailboxes() []Mailbox {
rows, err := s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
FROM mailboxes
ORDER BY updated_at DESC
`)
if err != nil {
panic(err)
}
defer rows.Close()
return scanMailboxes(rows)
}
func (s *PostgresStore) ListMailboxes(workspaceSlug string) []Mailbox {
rows, err := s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
FROM mailboxes
WHERE workspace_slug = $1
ORDER BY updated_at DESC
`, workspaceSlug)
if err != nil {
panic(err)
}
defer rows.Close()
return scanMailboxes(rows)
}
func (s *PostgresStore) GetMailboxByID(mailboxID string) (Mailbox, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
FROM mailboxes
WHERE id = $1
`, mailboxID)
return scanMailbox(row)
}
func (s *PostgresStore) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
var (
connection MailboxConnection
lastSynced sql.NullTime
syncError sql.NullString
)
err := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error,
imap_password_ciphertext, smtp_password_ciphertext
FROM mailboxes
WHERE id = $1
`, mailboxID).Scan(
&connection.ID,
&connection.WorkspaceSlug,
&connection.Label,
&connection.Email,
&connection.DisplayName,
&connection.IMAPHost,
&connection.IMAPPort,
&connection.IMAPUsername,
&connection.IMAPUseTLS,
&connection.SMTPHost,
&connection.SMTPPort,
&connection.SMTPUsername,
&connection.SMTPUseTLS,
&connection.CreatedAt,
&connection.UpdatedAt,
&lastSynced,
&connection.SyncStatus,
&syncError,
&connection.IMAPPasswordCiphertext,
&connection.SMTPPasswordCiphertext,
)
if err != nil {
return MailboxConnection{}, err
}
connection.LastSyncedAt = timePtr(lastSynced)
connection.SyncError = syncError.String
return connection, nil
}
func (s *PostgresStore) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
now := time.Now().UTC()
row := s.db.QueryRowContext(context.Background(), `
INSERT INTO mailboxes (
id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_password_ciphertext, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_password_ciphertext, smtp_use_tls, sync_status, sync_error, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'idle', '', $16, $16)
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
`,
uuid.NewString(),
input.WorkspaceSlug,
defaultString(input.Label, input.Email),
input.Email,
input.DisplayName,
input.IMAPHost,
input.IMAPPort,
input.IMAPUsername,
input.IMAPPasswordCiphertext,
input.IMAPUseTLS,
input.SMTPHost,
input.SMTPPort,
input.SMTPUsername,
input.SMTPPasswordCiphertext,
input.SMTPUseTLS,
now,
)
mailbox, err := scanMailbox(row)
if err != nil {
return Mailbox{}, err
}
if err := appendActivity(context.Background(), s.queries, mailbox.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", mailbox.Email)); err != nil {
return Mailbox{}, err
}
return mailbox, nil
}
func (s *PostgresStore) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
row := s.db.QueryRowContext(context.Background(), `
UPDATE mailboxes
SET sync_status = COALESCE(NULLIF($2, ''), sync_status),
sync_error = COALESCE($3, sync_error),
last_synced_at = COALESCE($4, last_synced_at),
updated_at = $5
WHERE id = $1
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
`, mailboxID, input.SyncStatus, nullableString(input.SyncError), nullableTime(input.LastSyncedAt), time.Now().UTC())
return scanMailbox(row)
}
func (s *PostgresStore) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
var (
rows *sql.Rows
err error
)
if mailboxID == "" {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
FROM mail_messages
WHERE workspace_slug = $1
ORDER BY received_at DESC
LIMIT 120
`, workspaceSlug)
} else {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
FROM mail_messages
WHERE workspace_slug = $1 AND mailbox_id = $2
ORDER BY received_at DESC
LIMIT 120
`, workspaceSlug, mailboxID)
}
if err != nil {
panic(err)
}
defer rows.Close()
return scanMailMessages(rows)
}
func (s *PostgresStore) GetMailMessageByID(messageID string) (MailMessage, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
FROM mail_messages
WHERE id = $1
`, messageID)
return scanMailMessage(row)
}
func (s *PostgresStore) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
now := time.Now().UTC()
for _, message := range messages {
_, err := tx.ExecContext(ctx, `
INSERT INTO mail_messages (
id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, $16, $16)
ON CONFLICT (mailbox_id, folder, remote_uid)
DO UPDATE SET
message_id = EXCLUDED.message_id,
from_address = EXCLUDED.from_address,
to_recipients = EXCLUDED.to_recipients,
cc_recipients = EXCLUDED.cc_recipients,
subject = EXCLUDED.subject,
snippet = EXCLUDED.snippet,
text_body = EXCLUDED.text_body,
html_body = EXCLUDED.html_body,
received_at = EXCLUDED.received_at,
is_read = EXCLUDED.is_read,
updated_at = EXCLUDED.updated_at
`,
uuid.NewString(),
message.WorkspaceSlug,
mailboxID,
message.RemoteUID,
message.MessageID,
defaultString(message.Folder, "INBOX"),
mustJSON(message.From),
mustJSON(message.To),
mustJSON(message.Cc),
message.Subject,
message.Snippet,
message.TextBody,
message.HTMLBody,
message.ReceivedAt,
message.IsRead,
now,
)
if err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (s *PostgresStore) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
row := s.db.QueryRowContext(context.Background(), `
UPDATE mail_messages
SET linked_task_id = $2, updated_at = $3
WHERE id = $1
RETURNING id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
`, messageID, taskID, time.Now().UTC())
return scanMailMessage(row)
}
func (s *PostgresStore) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
var (
rows *sql.Rows
err error
)
if mailboxID == "" {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE workspace_slug = $1
ORDER BY created_at DESC
LIMIT 120
`, workspaceSlug)
} else {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE workspace_slug = $1 AND mailbox_id = $2
ORDER BY created_at DESC
LIMIT 120
`, workspaceSlug, mailboxID)
}
if err != nil {
panic(err)
}
defer rows.Close()
return scanOutgoingMails(rows)
}
func (s *PostgresStore) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
rows, err := s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE status IN ('queued', 'scheduled')
AND (scheduled_for IS NULL OR scheduled_for <= $1)
ORDER BY created_at ASC
LIMIT $2
`, now, limit)
if err != nil {
panic(err)
}
defer rows.Close()
return scanOutgoingMails(rows)
}
func (s *PostgresStore) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE id = $1
`, outgoingMailID)
return scanOutgoingMail(row)
}
func (s *PostgresStore) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
now := time.Now().UTC()
row := s.db.QueryRowContext(context.Background(), `
INSERT INTO outgoing_mails (
id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULL, '', $12, $12)
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
`,
uuid.NewString(),
input.WorkspaceSlug,
input.MailboxID,
mustJSON(input.To),
mustJSON(input.Cc),
mustJSON(input.Bcc),
input.Subject,
input.TextBody,
input.HTMLBody,
input.Status,
nullableTime(input.ScheduledFor),
now,
)
return scanOutgoingMail(row)
}
func (s *PostgresStore) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
row := s.db.QueryRowContext(context.Background(), `
UPDATE outgoing_mails
SET status = COALESCE(NULLIF($2, ''), status),
sent_at = COALESCE($3, sent_at),
error_message = COALESCE($4, error_message),
updated_at = $5
WHERE id = $1
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
`, outgoingMailID, input.Status, nullableTime(input.SentAt), nullableString(input.Error), time.Now().UTC())
return scanOutgoingMail(row)
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanMailboxes(rows *sql.Rows) []Mailbox {
items := make([]Mailbox, 0)
for rows.Next() {
item, err := scanMailbox(rows)
if err != nil {
panic(err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
panic(err)
}
return items
}
func scanMailbox(row rowScanner) (Mailbox, error) {
var (
item Mailbox
lastSynced sql.NullTime
syncError sql.NullString
)
err := row.Scan(
&item.ID,
&item.WorkspaceSlug,
&item.Label,
&item.Email,
&item.DisplayName,
&item.IMAPHost,
&item.IMAPPort,
&item.IMAPUsername,
&item.IMAPUseTLS,
&item.SMTPHost,
&item.SMTPPort,
&item.SMTPUsername,
&item.SMTPUseTLS,
&item.CreatedAt,
&item.UpdatedAt,
&lastSynced,
&item.SyncStatus,
&syncError,
)
if err != nil {
return Mailbox{}, err
}
item.LastSyncedAt = timePtr(lastSynced)
item.SyncError = syncError.String
return item, nil
}
func scanMailMessages(rows *sql.Rows) []MailMessage {
items := make([]MailMessage, 0)
for rows.Next() {
item, err := scanMailMessage(rows)
if err != nil {
panic(err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
panic(err)
}
return items
}
func scanMailMessage(row rowScanner) (MailMessage, error) {
var (
item MailMessage
fromJSON []byte
toJSON []byte
ccJSON []byte
linkedTaskID sql.NullString
)
err := row.Scan(
&item.ID,
&item.WorkspaceSlug,
&item.MailboxID,
&item.RemoteUID,
&item.MessageID,
&item.Folder,
&fromJSON,
&toJSON,
&ccJSON,
&item.Subject,
&item.Snippet,
&item.TextBody,
&item.HTMLBody,
&item.ReceivedAt,
&item.IsRead,
&linkedTaskID,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
return MailMessage{}, err
}
if err := decodeJSONValue(fromJSON, &item.From); err != nil {
return MailMessage{}, err
}
to, err := decodeJSONSlice[MailAddress](toJSON)
if err != nil {
return MailMessage{}, err
}
cc, err := decodeJSONSlice[MailAddress](ccJSON)
if err != nil {
return MailMessage{}, err
}
item.To = to
item.Cc = cc
item.LinkedTaskID = stringPtr(linkedTaskID)
return item, nil
}
func scanOutgoingMails(rows *sql.Rows) []OutgoingMail {
items := make([]OutgoingMail, 0)
for rows.Next() {
item, err := scanOutgoingMail(rows)
if err != nil {
panic(err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
panic(err)
}
return items
}
func scanOutgoingMail(row rowScanner) (OutgoingMail, error) {
var (
item OutgoingMail
toJSON []byte
ccJSON []byte
bccJSON []byte
scheduledFor sql.NullTime
sentAt sql.NullTime
errorMessage sql.NullString
)
err := row.Scan(
&item.ID,
&item.WorkspaceSlug,
&item.MailboxID,
&toJSON,
&ccJSON,
&bccJSON,
&item.Subject,
&item.TextBody,
&item.HTMLBody,
&item.Status,
&scheduledFor,
&sentAt,
&errorMessage,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
return OutgoingMail{}, err
}
to, err := decodeJSONSlice[MailAddress](toJSON)
if err != nil {
return OutgoingMail{}, err
}
cc, err := decodeJSONSlice[MailAddress](ccJSON)
if err != nil {
return OutgoingMail{}, err
}
bcc, err := decodeJSONSlice[MailAddress](bccJSON)
if err != nil {
return OutgoingMail{}, err
}
item.To = to
item.Cc = cc
item.Bcc = bcc
item.ScheduledFor = timePtr(scheduledFor)
item.SentAt = timePtr(sentAt)
item.Error = errorMessage.String
return item, nil
}
func decodeJSONValue(raw []byte, target any) error {
if len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, target)
}
@@ -0,0 +1,133 @@
package store
import (
"time"
"github.com/google/uuid"
)
// CreateNotificationForTaskAssignment creates a notification when a task is assigned
func (s *PostgresStore) CreateNotificationForTaskAssignment(workspaceSlug, assigneeEmail, taskTitle, taskID string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: assigneeEmail,
Type: "task_assigned",
Title: "Task assigned to you",
Body: "You have been assigned to: " + taskTitle,
EntityType: strPtr("task"),
EntityID: strPtr(taskID),
})
}
// CreateNotificationForMention creates a notification when a user is mentioned
func (s *PostgresStore) CreateNotificationForMention(workspaceSlug, mentionedEmail, mentionerName, entityType, entityID, context string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: mentionedEmail,
Type: "mention",
Title: mentionerName + " mentioned you",
Body: context,
EntityType: strPtr(entityType),
EntityID: strPtr(entityID),
})
}
// CreateNotificationForComment creates a notification for a new comment on an entity
func (s *PostgresStore) CreateNotificationForComment(workspaceSlug, ownerEmail, commenterName, entityType, entityID, entityTitle string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: ownerEmail,
Type: "comment",
Title: commenterName + " commented on " + entityTitle,
Body: "New comment on " + entityType,
EntityType: strPtr(entityType),
EntityID: strPtr(entityID),
})
}
// CreateNotificationForTaskCompletion creates a notification when a task is completed
func (s *PostgresStore) CreateNotificationForTaskCompletion(workspaceSlug, assignerEmail, taskTitle, taskID string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: assignerEmail,
Type: "task_completed",
Title: "Task completed: " + taskTitle,
Body: "A task you assigned has been completed",
EntityType: strPtr("task"),
EntityID: strPtr(taskID),
})
}
// CreateNotificationForEventReminder creates a notification for an upcoming event
func (s *PostgresStore) CreateNotificationForEventReminder(workspaceSlug, userEmail, eventTitle, eventID string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: userEmail,
Type: "event_reminder",
Title: "Upcoming event: " + eventTitle,
Body: "Your event is starting soon",
EntityType: strPtr("event"),
EntityID: strPtr(eventID),
})
}
// TriggerWebhooks triggers all webhooks for a given event type
func (s *PostgresStore) TriggerWebhooks(workspaceSlug, eventType string, payload map[string]interface{}) {
// Get all active webhooks for this workspace
rows, err := s.db.Query(`
SELECT id, url, secret, events
FROM webhooks
WHERE workspace_slug = $1 AND active = true
`, workspaceSlug)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var id, url, secret, eventsJSON string
if err := rows.Scan(&id, &url, &secret, &eventsJSON); err != nil {
continue
}
// Check if this webhook subscribes to the event
// Simple string contains check for the event type in the JSON array
if !containsEvent(eventsJSON, eventType) {
continue
}
// Update last triggered timestamp
s.db.Exec(`UPDATE webhooks SET last_triggered_at = $1 WHERE id = $2`, time.Now().UTC(), id)
// Webhook delivery would happen here in a goroutine
// For now, we just mark it as triggered
go deliverWebhook(url, secret, eventType, payload)
}
}
func strPtr(s string) *string {
return &s
}
func containsEvent(eventsJSON, eventType string) bool {
// Simple check - in production would parse JSON properly
return len(eventsJSON) > 2 &&
(eventsJSON == "[]" ||
eventsJSON == "[\""+eventType+"\"]" ||
containsSubstring(eventsJSON, "\""+eventType+"\""))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func deliverWebhook(url, secret, eventType string, payload map[string]interface{}) {
// Webhook delivery implementation
// In production, this would make an HTTP POST request
// with proper signature using the secret
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestMigrationsDirUsesEnvOverrideWhenValid(t *testing.T) {
tempDir := t.TempDir()
customDir := filepath.Join(tempDir, "migrations")
if err := os.MkdirAll(customDir, 0o755); err != nil {
t.Fatalf("create temp migrations dir: %v", err)
}
t.Setenv("DB_MIGRATIONS_DIR", customDir)
if got := migrationsDir(); got != customDir {
t.Fatalf("migrationsDir() = %q, want %q", got, customDir)
}
}
func TestMigrationsDirFallsBackWhenEnvOverrideIsInvalid(t *testing.T) {
t.Setenv("DB_MIGRATIONS_DIR", filepath.Join(t.TempDir(), "missing"))
got := migrationsDir()
if got == "" {
t.Fatal("migrationsDir() should never return empty path")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
package store
import "testing"
func TestStateUpdateMemberRejectsLastActiveOwnerChange(t *testing.T) {
state := NewSeededState("test")
owner := findMemberByRole(t, state, "owner")
nextRole := "member"
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole}); err == nil {
t.Fatalf("expected last active owner demotion to fail")
}
nextStatus := "removed"
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Status: &nextStatus}); err == nil {
t.Fatalf("expected last active owner deactivation to fail")
}
}
func TestStateUpdateMemberAllowsOwnerChangeWhenAnotherOwnerExists(t *testing.T) {
state := NewSeededState("test")
owner := findMemberByRole(t, state, "owner")
state.Members = append(state.Members, Member{
ID: "member-owner-2",
WorkspaceSlug: owner.WorkspaceSlug,
Name: "Backup Owner",
Email: "backup-owner@productier.app",
Role: "owner",
Status: "active",
})
nextRole := "admin"
updated, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole})
if err != nil {
t.Fatalf("expected owner demotion to succeed with another active owner: %v", err)
}
if updated.Role != "admin" {
t.Fatalf("expected updated role admin, got %s", updated.Role)
}
}
func TestStateRevokeInviteRules(t *testing.T) {
state := NewSeededState("test")
if len(state.Invites) == 0 {
t.Fatalf("seed state has no invites")
}
invite := state.Invites[0]
if err := state.RevokeInvite(invite.ID); err != nil {
t.Fatalf("expected pending invite revoke to succeed: %v", err)
}
if _, err := state.GetInviteByID(invite.ID); err == nil {
t.Fatalf("expected revoked invite to be absent")
}
}
func TestStateRevokeInviteRejectedWhenAccepted(t *testing.T) {
state := NewSeededState("test")
if len(state.Invites) == 0 {
t.Fatalf("seed state has no invites")
}
invite := state.Invites[0]
if _, err := state.AcceptInvite(invite.Token, AcceptInviteInput{Name: "Taylor", Email: invite.Email}); err != nil {
t.Fatalf("accept invite setup failed: %v", err)
}
if err := state.RevokeInvite(invite.ID); err == nil {
t.Fatalf("expected revoke of accepted invite to fail")
}
}
func findMemberByRole(t *testing.T, state *State, role string) Member {
t.Helper()
for _, member := range state.Members {
if member.Role == role && member.Status == "active" {
return member
}
}
t.Fatalf("no active member with role %s", role)
return Member{}
}
+103
View File
@@ -0,0 +1,103 @@
package store
import "time"
type Store interface {
ListWorkspaces() []Workspace
ListMembers(workspaceSlug string) []Member
GetMemberByID(memberID string) (Member, error)
UpdateMember(memberID string, input UpdateMemberInput) (Member, error)
ListInvites(workspaceSlug string) []Invite
GetInviteByID(inviteID string) (Invite, error)
GetInviteByToken(token string) (Invite, error)
CreateInvite(input CreateInviteInput) Invite
RevokeInvite(inviteID string) error
AcceptInvite(token string, input AcceptInviteInput) (Invite, error)
ListActivities(workspaceSlug string) []ActivityEntry
ListBoardGroups(workspaceSlug string) []BoardGroup
GetBoardGroupByID(groupID string) (BoardGroup, error)
CreateBoardGroup(input CreateBoardGroupInput) BoardGroup
UpdateBoardGroup(groupID string, input UpdateBoardGroupInput) (BoardGroup, error)
ListLabels(workspaceSlug string) []Label
CreateLabel(input CreateLabelInput) Label
ListTasks(workspaceSlug string) []Task
GetTaskByID(taskID string) (Task, error)
CreateTask(input CreateTaskInput) Task
UpdateTask(taskID string, input UpdateTaskInput) (Task, error)
ListEvents(workspaceSlug string) []CalendarEvent
GetEventByID(eventID string) (CalendarEvent, error)
CreateEvent(input CreateEventInput) CalendarEvent
UpdateEvent(eventID string, input UpdateEventInput) (CalendarEvent, error)
ListNotes(workspaceSlug string) []Note
GetNoteByID(noteID string) (Note, error)
CreateNote(input CreateNoteInput) Note
UpdateNote(noteID string, input UpdateNoteInput) (Note, error)
ListFocusSessions(workspaceSlug string) []FocusSession
GetFocusSessionByID(sessionID string) (FocusSession, error)
CreateFocusSession(input CreateFocusSessionInput) FocusSession
UpdateFocusSession(sessionID string, input UpdateFocusSessionInput) (FocusSession, error)
ListAllMailboxes() []Mailbox
ListMailboxes(workspaceSlug string) []Mailbox
GetMailboxByID(mailboxID string) (Mailbox, error)
GetMailboxConnection(mailboxID string) (MailboxConnection, error)
CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error)
UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error)
ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage
GetMailMessageByID(messageID string) (MailMessage, error)
UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error
LinkMailMessageTask(messageID string, taskID string) (MailMessage, error)
ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail
ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail
GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error)
CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error)
UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error)
// CRM
ListContacts(workspaceSlug string) []Contact
GetContactByID(contactID string) (Contact, error)
CreateContact(input CreateContactInput) Contact
UpdateContact(contactID string, input UpdateContactInput) (Contact, error)
DeleteContact(contactID string) error
ListCompanies(workspaceSlug string) []Company
GetCompanyByID(companyID string) (Company, error)
CreateCompany(input CreateCompanyInput) Company
UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error)
DeleteCompany(companyID string) error
LinkContactToTask(contactID, taskID string) error
UnlinkContactFromTask(contactID, taskID string) error
ListContactsForTask(taskID string) []Contact
LinkContactToEvent(contactID, eventID string) error
ListContactsForEvent(eventID string) []Contact
// Inbox
ListInboxItems(workspaceSlug string) []InboxItem
CreateInboxItem(input CreateInboxItemInput) InboxItem
ProcessInboxItem(itemID string, entityType, entityID string) error
DeleteInboxItem(itemID string) error
// Time tracking
ListTimeEntries(workspaceSlug string) []TimeEntry
CreateTimeEntry(input CreateTimeEntryInput) TimeEntry
UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error)
DeleteTimeEntry(entryID string) error
// Saved views
ListSavedViews(workspaceSlug, entityType string) []SavedView
CreateSavedView(input CreateSavedViewInput) SavedView
DeleteSavedView(viewID string) error
// Integrations
ListIntegrations(workspaceSlug string) []Integration
GetIntegrationByID(integrationID string) (Integration, error)
CreateIntegration(input CreateIntegrationInput) Integration
DeleteIntegration(integrationID string) error
// Webhooks
ListWebhooks(workspaceSlug string) []Webhook
CreateWebhook(input CreateWebhookInput) Webhook
DeleteWebhook(webhookID string) error
// Notifications
ListNotifications(userEmail string, limit int) []Notification
CreateNotification(input CreateNotificationInput) Notification
MarkNotificationRead(notificationID string) error
MarkAllNotificationsRead(userEmail string) error
UnreadNotificationCount(userEmail string) int
// Presence
UpdatePresence(input UpdatePresenceInput) Presence
ListPresence(workspaceSlug string, entityType, entityID string) []Presence
ClearPresence(workspaceSlug, userEmail string) error
}
BIN
View File
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
# Productier Backend Remote Deployment Environment
# Copy to .env for remote/self-hosted deployment
# Application Environment
APP_ENV=production
API_PORT=8080
API_SHUTDOWN_TIMEOUT=15s
# Database (REQUIRED - update with your PostgreSQL connection)
DATABASE_URL=postgres://productier:your-secure-password@your-postgres-host:5432/productier?sslmode=require
# Auth Service (REQUIRED - URL where auth service is accessible)
AUTH_SERVICE_URL=http://your-auth-host:3001
# Secrets (REQUIRED - generate strong random secrets)
BETTER_AUTH_SECRET=generate-a-32-plus-char-random-secret-here
MAIL_ENCRYPTION_KEY=generate-another-32-plus-char-random-secret-here
# CORS (REQUIRED - comma-separated allowed origins)
CORS_ALLOW_ORIGINS=https://your-frontend-domain.com,https://your-api-domain.com
# File Storage
FILE_STORAGE_PROVIDER=local
FILE_STORAGE_DIR=/tmp/uploads
# Optional: S3-compatible storage
# FILE_STORAGE_PROVIDER=s3
# S3_ENDPOINT=https://your-s3-endpoint
# S3_REGION=us-east-1
# S3_BUCKET=productier
# S3_ACCESS_KEY=your-access-key
# S3_SECRET_KEY=your-secret-key
# S3_USE_PATH_STYLE=false
# Optional: Metrics auth token
# METRICS_AUTH_TOKEN=your-metrics-token
# Database migrations directory
DB_MIGRATIONS_DIR=/app/migrations
+11
View File
@@ -0,0 +1,11 @@
version: "2"
sql:
- engine: "postgresql"
queries: "internal/db/queries"
schema: "internal/db/migrations"
gen:
go:
package: "db"
out: "internal/db/generated"
sql_package: "database/sql"
+4
View File
@@ -0,0 +1,4 @@
.env
!.env.example
node_modules/
dist/
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /workspace
COPY package.json package-lock.json ./
COPY apps/frontend/package.json apps/frontend/package.json
COPY apps/backend/auth-service/package.json apps/backend/auth-service/package.json
COPY packages/api-client/package.json packages/api-client/package.json
COPY packages/openclaw-plugin/package.json packages/openclaw-plugin/package.json
RUN npm ci
FROM deps AS build
WORKDIR /workspace
ARG VITE_FRONTEND_URL=http://localhost:3000
ARG VITE_AUTH_URL=http://localhost:3001
ARG VITE_API_URL=http://localhost:8080
ARG VITE_DEV_MAILBOX_ENABLED=true
ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL}
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_DEV_MAILBOX_ENABLED=${VITE_DEV_MAILBOX_ENABLED}
COPY . .
RUN npm run gen:api && npm run build -w apps/frontend
FROM nginx:alpine AS runtime
WORKDIR /usr/share/nginx/html
COPY --from=build /workspace/apps/frontend/dist .
COPY apps/frontend/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+74
View File
@@ -0,0 +1,74 @@
# SolidStart → SolidJS + Vite Migration
## What Changed
### Removed
- `@solidjs/start` - Full-stack framework
- `vinxi` - Build tool
- `.vinxi/` and `.output/` build directories
- `entry-client.tsx` and `entry-server.tsx` - SSR entry points
- `app.config.ts` - SolidStart config
### Added
- `vite` - Fast build tool
- `vite-plugin-solid` - SolidJS plugin for Vite
- `index.html` - Standard HTML entry point
- `src/index.tsx` - Client-side entry point
- `vite.config.ts` - Vite configuration
- `nginx.conf` - Static file serving config
### Updated
- `package.json` - New scripts and dependencies
- `app.tsx` - Manual routing instead of FileRoutes
- `tsconfig.json` - Vite types instead of Vinxi
- `Dockerfile` - Static build with nginx instead of Node server
- `.gitignore` - Removed SolidStart artifacts
## Benefits
1. **Simpler**: No SSR complexity, just a clean SPA
2. **Faster**: Vite's dev server is lightning fast
3. **Cleaner**: Standard Vite setup everyone knows
4. **Smaller**: Static files served by nginx (much lighter than Node)
5. **Easier to debug**: No framework magic, just Vite + SolidJS
## How to Run
```bash
# Install dependencies
npm install
# Development
npm run dev
# Build
npm run build
# Preview production build
npm run preview
```
## Routing
Routes are now explicitly defined in `src/app.tsx` instead of file-based routing:
```tsx
<Route path="/app/:workspaceSlug">
<Route path="/today" component={TodayRoute} />
<Route path="/calendar" component={CalendarRoute} />
// ... etc
</Route>
```
All route components are lazy-loaded for optimal performance.
## Docker
The Dockerfile now builds static files and serves them with nginx on port 80 (instead of Node on port 3000).
## Notes
- Service worker registration still works
- All auth flows remain unchanged
- API proxy configured in vite.config.ts for `/api/auth`
- All existing components and pages work as-is
+30
View File
@@ -0,0 +1,30 @@
# SolidStart
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
## Creating a project
```bash
# create a new project in the current directory
npm init solid@latest
# create a new project in my-app
npm init solid@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
+88
View File
@@ -0,0 +1,88 @@
# SolidJS + Vite Migration Complete
## Summary
The Productier frontend has been successfully migrated from SolidStart to pure SolidJS + Vite. The application is now running correctly with all features intact.
## What Was Changed
### 1. Vite Configuration (`vite.config.ts`)
- Updated the path resolution to use ES modules (`fileURLToPath` and `dirname`)
- Maintained the `~` alias for clean imports
- Kept the proxy configuration for the auth service
### 2. Package Configuration (`package.json`)
- Already configured correctly with:
- `solid-js` for the framework
- `vite-plugin-solid` for Vite integration
- `@solidjs/router` for routing
- No SolidStart dependencies
### 3. Application Structure
- Entry point: `src/index.tsx` - Uses `solid-js/web` render
- Main app: `src/app.tsx` - Pure SolidJS Router setup
- Routes: All routes in `src/routes/` are standard SolidJS components
- No server-side rendering (SSR) - Pure client-side app
## Running the Application
### Development Mode
1. Start the backend services:
```bash
docker compose up
```
2. Start the frontend dev server:
```bash
cd apps/frontend
npm run dev
```
3. Open http://localhost:5173
### Production Build
```bash
cd apps/frontend
npm run build
```
The build output will be in `apps/frontend/dist/`
## Services
- Frontend: http://localhost:5173 (Vite dev server)
- Auth Service: http://localhost:43001 (Node.js)
- API Service: http://localhost:48080 (Go)
- PostgreSQL: localhost:5432 (Docker)
## Key Features
- ✅ Hot Module Replacement (HMR) with Vite
- ✅ Fast refresh for SolidJS components
- ✅ TypeScript support
- ✅ Tailwind CSS v4
- ✅ Client-side routing with @solidjs/router
- ✅ Better Auth integration
- ✅ Offline-first architecture with local state
- ✅ Production build optimization
## Tech Stack
- **Framework**: SolidJS 1.9.5
- **Build Tool**: Vite 6.0.7
- **Router**: @solidjs/router 0.15.0
- **Styling**: Tailwind CSS 4.0.7
- **Auth**: Better Auth 1.5.6
- **Icons**: Lucide Solid
- **Date Handling**: date-fns
- **Markdown**: marked
## Notes
- The app is fully client-side rendered (no SSR)
- All routes are lazy-loaded for optimal performance
- The `~` alias resolves to `src/` directory
- Auth session is managed via Better Auth
- State management uses SolidJS stores
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Productier - Calm productivity workspace" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<title>Productier</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@productier/frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@productier/api-client": "*",
"@solidjs/router": "^0.15.0",
"better-auth": "^1.5.6",
"date-fns": "^4.1.0",
"lucide-solid": "^0.542.0",
"marked": "^16.3.0",
"solid-js": "^1.9.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@types/node": "^25.5.2",
"tailwindcss": "^4.0.7",
"vite": "^6.0.7",
"vite-plugin-solid": "^2.10.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

+17
View File
@@ -0,0 +1,17 @@
{
"name": "Productier",
"short_name": "Productier",
"description": "Calm planning, boards, notes, and focus sessions in a lightweight PWA.",
"start_url": "/",
"display": "standalone",
"background_color": "#f4efe8",
"theme_color": "#e57d7d",
"icons": [
{
"src": "/favicon.ico",
"sizes": "48x48",
"type": "image/x-icon"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
const CACHE_NAME = "productier-v1";
const PRECACHE = ["/", "/manifest.json", "/favicon.ico"];
self.addEventListener("install", event => {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE)));
});
self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))),
),
);
});
self.addEventListener("fetch", event => {
if (event.request.method !== "GET") {
return;
}
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
return cached;
}
return fetch(event.request)
.then(response => {
const copy = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
return response;
})
.catch(() => caches.match("/"));
}),
);
});
+8
View File
@@ -0,0 +1,8 @@
# Productier Frontend Remote Deployment Environment
# These are build-time environment variables for the frontend
# Public URLs (REQUIRED - where your services are accessible)
VITE_FRONTEND_URL=https://your-frontend-domain.com
VITE_AUTH_URL=https://your-auth-domain.com
VITE_API_URL=https://your-api-domain.com
VITE_DEV_MAILBOX_ENABLED=false
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
import { Router, Route, useLocation, useNavigate } from "@solidjs/router";
import { createEffect, createSignal, on, onCleanup, Show, Suspense, lazy } from "solid-js";
import AppShell from "~/components/app-shell";
import { authClient } from "~/lib/auth-client";
import { AppProvider } from "~/lib/app-context";
import "./app.css";
// Lazy load routes
const LoginPage = lazy(() => import("~/routes/login"));
const NotFoundPage = lazy(() => import("~/routes/[...404]"));
const AcceptInvitePage = lazy(() => import("~/routes/accept-invite/[token]"));
const TodayRoute = lazy(() => import("~/routes/app/[workspaceSlug]/today"));
const CalendarRoute = lazy(() => import("~/routes/app/[workspaceSlug]/calendar"));
const BoardRoute = lazy(() => import("~/routes/app/[workspaceSlug]/board"));
const MailRoute = lazy(() => import("~/routes/app/[workspaceSlug]/mail"));
const NotesRoute = lazy(() => import("~/routes/app/[workspaceSlug]/notes"));
const FocusRoute = lazy(() => import("~/routes/app/[workspaceSlug]/focus"));
const SettingsRoute = lazy(() => import("~/routes/app/[workspaceSlug]/settings"));
function RootFrame(props: { children: unknown }) {
const location = useLocation();
const navigate = useNavigate();
const session = authClient.useSession();
const [sessionTimeoutReached, setSessionTimeoutReached] = createSignal(false);
let sessionTimeoutId: number | undefined;
const inApp = () => location.pathname.startsWith("/app/");
const inLogin = () => location.pathname === "/login";
createEffect(
on(
() => session().isPending,
isPending => {
if (!isPending) {
setSessionTimeoutReached(false);
if (sessionTimeoutId !== undefined) {
window.clearTimeout(sessionTimeoutId);
sessionTimeoutId = undefined;
}
return;
}
if (sessionTimeoutId !== undefined) {
return;
}
sessionTimeoutId = window.setTimeout(() => {
setSessionTimeoutReached(true);
sessionTimeoutId = undefined;
}, 2000);
},
{ defer: true }
)
);
onCleanup(() => {
if (sessionTimeoutId !== undefined) {
window.clearTimeout(sessionTimeoutId);
}
});
createEffect(() => {
const current = session();
if (current.isPending && !sessionTimeoutReached()) {
return;
}
if (inApp() && !current.data) {
navigate("/login", { replace: true });
return;
}
if (inLogin() && current.data) {
navigate("/app/personal/today", { replace: true });
}
});
const loadingGate = () => inApp() && session().isPending && !sessionTimeoutReached();
const canRenderApp = () => !inApp() || Boolean(session().data);
return (
<Show
when={!loadingGate()}
fallback={
<main class="flex min-h-screen items-center justify-center px-4">
<div class="app-surface w-full max-w-xl rounded-[2rem] p-8 text-center">
<p class="section-title">Securing Session</p>
<h1 class="mt-3 text-3xl font-extrabold tracking-[-0.05em]">Loading your workspace shell</h1>
<p class="mt-3 text-sm text-soft">Checking the active Better Auth session before opening Productier.</p>
</div>
</main>
}
>
<Show when={inApp()} fallback={<Suspense>{props.children}</Suspense>}>
<Show when={canRenderApp()}>
<AppShell>
<Suspense>{props.children}</Suspense>
</AppShell>
</Show>
</Show>
</Show>
);
}
export default function App() {
return (
<AppProvider>
<Router root={props => <RootFrame>{props.children}</RootFrame>}>
<Route path="/" component={() => {
const navigate = useNavigate();
createEffect(() => navigate("/login", { replace: true }));
return null;
}} />
<Route path="/login" component={LoginPage} />
<Route path="/accept-invite/:token" component={AcceptInvitePage} />
<Route path="/app/:workspaceSlug">
<Route path="/today" component={TodayRoute} />
<Route path="/calendar" component={CalendarRoute} />
<Route path="/board" component={BoardRoute} />
<Route path="/mail" component={MailRoute} />
<Route path="/notes" component={NotesRoute} />
<Route path="/focus" component={FocusRoute} />
<Route path="/settings" component={SettingsRoute} />
<Route path="/" component={() => {
const navigate = useNavigate();
const location = useLocation();
createEffect(() => {
const slug = location.pathname.split("/")[2];
navigate(`/app/${slug}/today`, { replace: true });
});
return null;
}} />
</Route>
<Route path="*" component={NotFoundPage} />
</Router>
</AppProvider>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { A, useLocation } from "@solidjs/router";
import {
Bell,
Building2,
Calendar,
CalendarDays,
Inbox,
LayoutGrid,
LayoutList,
Link,
LogOut,
Mail,
Moon,
NotebookText,
Search,
Settings,
Sun,
TimerReset,
Users,
Wifi,
WifiOff
} from "lucide-solid";
import { For, Show, type JSX } from "solid-js";
import { useApp } from "~/lib/app-context";
import { CommandPalette } from "./command-palette";
import { GlobalSearch } from "./global-search";
import { NotificationsDropdown } from "./notifications-dropdown";
const navItems = [
{ href: "today", label: "Today", icon: LayoutGrid },
{ href: "inbox", label: "Inbox", icon: Inbox },
{ href: "calendar", label: "Calendar", icon: CalendarDays },
{ href: "board", label: "Board", icon: Bell },
{ href: "list", label: "List", icon: LayoutList },
{ href: "timeline", label: "Timeline", icon: Calendar },
{ href: "mail", label: "Mail", icon: Mail },
{ href: "notes", label: "Notes", icon: NotebookText },
{ href: "contacts", label: "Contacts", icon: Users },
{ href: "companies", label: "Companies", icon: Building2 },
{ href: "focus", label: "Focus", icon: TimerReset },
{ href: "integrations", label: "Integrations", icon: Link },
{ href: "settings", label: "Settings", icon: Settings }
];
export default function AppShell(props: { children: JSX.Element }) {
const app = useApp();
const location = useLocation();
const workspaceSlug = () => location.pathname.split("/")[2] || app.primaryWorkspace()?.slug || "personal";
const workspace = () => app.workspaceForSlug(workspaceSlug()) ?? app.primaryWorkspace();
const pendingSyncCount = () => app.state.offlineQueue.filter(item => item.status !== "synced").length;
return (
<>
<CommandPalette />
<GlobalSearch />
<div class="min-h-screen flex" style="position: relative; z-index: 1;">
{/* Desktop Sidebar */}
<aside class="hidden w-72 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] lg:flex lg:flex-col relative">
{/* Decorative accent line */}
<div class="absolute top-0 left-0 w-1 h-full bg-gradient-to-b from-[var(--accent)] via-[var(--secondary)] to-transparent opacity-60" />
{/* Logo */}
<div class="border-b border-[var(--border)] px-7 py-6 relative">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--accent)] via-[var(--accent-hover)] to-[var(--secondary)] text-white text-base font-bold shadow-lg relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-tr from-transparent via-white/20 to-transparent" />
<span class="relative">P</span>
</div>
<div>
<span class="text-xl font-semibold tracking-tight block" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 144;">Productier</span>
<span class="text-[10px] font-medium tracking-wider uppercase text-[var(--text-muted)] block mt-0.5">Workspace</span>
</div>
</div>
</div>
{/* Workspace selector */}
<div class="border-b border-[var(--border)] px-6 py-5">
<div class="group flex items-center gap-3 rounded-2xl bg-gradient-to-br from-[var(--bg-subtle)] to-[var(--bg-muted)] px-5 py-4 transition-all duration-300 hover:shadow-md hover:scale-[1.02] cursor-pointer relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-[var(--accent)]/5 to-[var(--secondary)]/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--accent-subtle)] via-[var(--accent-muted)] to-[var(--secondary-subtle)] text-[var(--accent)] text-base font-bold shadow-sm relative z-10">
{workspace()?.name?.charAt(0) ?? "P"}
</div>
<div class="min-w-0 flex-1 relative z-10">
<p class="truncate text-sm font-semibold">{workspace()?.name}</p>
<p class="text-xs text-[var(--text-muted)] capitalize flex items-center gap-1.5 mt-0.5">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-[var(--success)]" />
{workspace()?.role}
</p>
</div>
</div>
</div>
{/* Navigation */}
<nav class="flex-1 overflow-y-auto scrollbar-thin p-5">
<div class="space-y-2">
<For each={navItems}>
{item => {
const Icon = item.icon;
const href = () => `/app/${workspaceSlug()}/${item.href}`;
const active = () => location.pathname === href();
return (
<A
href={href()}
class="group flex items-center gap-3.5 rounded-xl px-4 py-3.5 text-sm font-medium transition-all duration-200 relative overflow-hidden"
classList={{
"bg-gradient-to-r from-[var(--accent)] to-[var(--accent-hover)] text-white shadow-lg shadow-[var(--accent)]/20": active(),
"text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg-subtle)]": !active()
}}
>
<Show when={active()}>
<div class="absolute inset-0 bg-gradient-to-r from-white/10 to-transparent opacity-50" />
</Show>
<Icon size={20} class="relative z-10 transition-transform duration-200 group-hover:scale-110" />
<span class="relative z-10">{item.label}</span>
<Show when={active()}>
<div class="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-white/40 rounded-l-full" />
</Show>
</A>
);
}}
</For>
</div>
</nav>
{/* Sync status */}
<div class="border-t border-[var(--border)] px-6 py-4 bg-[var(--bg-subtle)]">
<div class="flex items-center gap-3 text-xs">
<Show when={app.online()} fallback={
<div class="flex items-center gap-2.5 px-3 py-2 rounded-lg bg-[var(--error-muted)] text-[var(--error)] w-full">
<WifiOff size={16} />
<span class="font-medium">Offline mode</span>
</div>
}>
<div class="flex items-center gap-2.5 px-3 py-2 rounded-lg w-full transition-colors duration-200"
classList={{
"bg-[var(--warning-muted)] text-[var(--warning)]": pendingSyncCount() > 0,
"bg-[var(--success-muted)] text-[var(--success)]": pendingSyncCount() === 0
}}
>
<div class={`status-dot ${pendingSyncCount() > 0 ? 'syncing' : 'online'}`} />
<span class="font-medium">
{pendingSyncCount() > 0 ? `Syncing ${pendingSyncCount()}` : "All synced"}
</span>
</div>
</Show>
</div>
</div>
{/* User section */}
<div class="border-t border-[var(--border)] px-6 py-5">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">{app.state.session?.name}</p>
<p class="truncate text-xs text-[var(--text-muted)] mt-0.5">{app.state.session?.email}</p>
</div>
<button
class="group flex h-10 w-10 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--error-muted)] hover:text-[var(--error)] hover:shadow-md hover:scale-105"
onClick={() => void app.logout()}
aria-label="Sign out"
>
<LogOut size={18} class="transition-transform duration-200 group-hover:translate-x-0.5" />
</button>
</div>
</div>
</aside>
{/* Main content area */}
<div class="flex min-w-0 flex-1 flex-col">
{/* Header */}
<header class="sticky top-0 z-30 border-b border-[var(--border)] glass backdrop-blur-xl">
<div class="flex h-16 items-center justify-between px-6 lg:px-8">
{/* Left: Page context */}
<div class="flex items-center gap-4">
<div class="hidden lg:block">
<h1 class="text-lg font-semibold tracking-tight" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 120;">{workspace()?.name}</h1>
</div>
<div class="lg:hidden flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--accent)] to-[var(--secondary)] text-white text-sm font-bold shadow-md">
P
</div>
<span class="text-base font-semibold" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 120;">Productier</span>
</div>
</div>
{/* Right: Actions */}
<div class="flex items-center gap-3">
<NotificationsDropdown />
<button
class="group flex items-center gap-2 px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] text-[var(--text-muted)] text-sm hover:bg-[var(--surface-hover)] transition-colors"
onClick={() => {
const event = new KeyboardEvent("keydown", { key: "/", metaKey: true });
window.dispatchEvent(event);
}}
>
<Search size={16} />
<span class="hidden sm:inline">Search</span>
<kbd class="hidden sm:inline px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-xs">/</kbd>
</button>
<Show when={pendingSyncCount() > 0}>
<span class="hidden rounded-full bg-[var(--warning-muted)] px-3.5 py-2 text-xs font-semibold text-[var(--warning)] sm:inline-flex items-center gap-2 shadow-sm">
<div class="status-dot syncing" />
{pendingSyncCount()} pending
</span>
</Show>
<button
class="group flex h-11 w-11 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--bg-subtle)] hover:text-[var(--text)] hover:shadow-md hover:scale-105"
onClick={() => app.setTheme(app.state.theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
<Show when={app.state.theme === "dark"} fallback={<Moon size={19} class="transition-transform duration-200 group-hover:rotate-12" />}>
<Sun size={19} class="transition-transform duration-200 group-hover:rotate-45" />
</Show>
</button>
</div>
</div>
</header>
{/* Main content */}
<main class="flex-1 overflow-y-auto scrollbar-thin p-6 lg:p-10">
<div class="mx-auto max-w-7xl">
{props.children}
</div>
</main>
{/* Mobile bottom navigation */}
<nav class="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--border)] glass backdrop-blur-xl lg:hidden">
<div class="flex h-20 items-center justify-around px-2">
<For each={navItems.slice(0, 5)}>
{item => {
const Icon = item.icon;
const href = `/app/${workspaceSlug()}/${item.href}`;
const active = () => location.pathname === href;
return (
<A
href={href}
class="group flex flex-col items-center gap-1.5 rounded-xl px-5 py-2.5 transition-all duration-200"
classList={{
"text-[var(--accent)]": active(),
"text-[var(--text-muted)]": !active()
}}
>
<div class="relative">
<Icon size={22} class="transition-transform duration-200 group-active:scale-90" />
<Show when={active()}>
<div class="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-[var(--accent)]" />
</Show>
</div>
<span class="text-[10px] font-semibold tracking-wide">{item.label}</span>
</A>
);
}}
</For>
</div>
</nav>
</div>
</div>
</>
);
}
@@ -0,0 +1,154 @@
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { Search, X } from "lucide-solid";
import { useNavigate } from "@solidjs/router";
import { useApp } from "~/lib/app-context";
interface Command {
id: string;
label: string;
shortcut?: string;
action: () => void;
category: string;
}
export function CommandPalette() {
const app = useApp();
const navigate = useNavigate();
const [open, setOpen] = createSignal(false);
const [query, setQuery] = createSignal("");
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const commands: Command[] = [
// Navigation
{ id: "nav-today", label: "Go to Today", shortcut: "G T", action: () => navigate(`/app/${workspaceSlug()}/today`), category: "Navigation" },
{ id: "nav-board", label: "Go to Board", shortcut: "G B", action: () => navigate(`/app/${workspaceSlug()}/board`), category: "Navigation" },
{ id: "nav-calendar", label: "Go to Calendar", shortcut: "G C", action: () => navigate(`/app/${workspaceSlug()}/calendar`), category: "Navigation" },
{ id: "nav-notes", label: "Go to Notes", shortcut: "G N", action: () => navigate(`/app/${workspaceSlug()}/notes`), category: "Navigation" },
{ id: "nav-contacts", label: "Go to Contacts", action: () => navigate(`/app/${workspaceSlug()}/contacts`), category: "Navigation" },
{ id: "nav-companies", label: "Go to Companies", action: () => navigate(`/app/${workspaceSlug()}/companies`), category: "Navigation" },
{ id: "nav-inbox", label: "Go to Inbox", action: () => navigate(`/app/${workspaceSlug()}/inbox`), category: "Navigation" },
{ id: "nav-focus", label: "Go to Focus", action: () => navigate(`/app/${workspaceSlug()}/focus`), category: "Navigation" },
{ id: "nav-settings", label: "Go to Settings", action: () => navigate(`/app/${workspaceSlug()}/settings`), category: "Navigation" },
// Actions
{ id: "action-new-task", label: "Create new task", shortcut: "N T", action: () => navigate(`/app/${workspaceSlug()}/board`), category: "Actions" },
{ id: "action-new-event", label: "Create new event", action: () => navigate(`/app/${workspaceSlug()}/calendar`), category: "Actions" },
{ id: "action-new-contact", label: "Create new contact", action: () => navigate(`/app/${workspaceSlug()}/contacts`), category: "Actions" },
{ id: "action-new-company", label: "Create new company", action: () => navigate(`/app/${workspaceSlug()}/companies`), category: "Actions" },
{ id: "action-capture", label: "Quick capture", action: () => navigate(`/app/${workspaceSlug()}/inbox`), category: "Actions" },
// Edit
{ id: "edit-undo", label: "Undo", shortcut: "Ctrl+Z", action: () => app.undo(), category: "Edit" },
{ id: "edit-redo", label: "Redo", shortcut: "Ctrl+Shift+Z", action: () => app.redo(), category: "Edit" },
// Theme
{ id: "theme-toggle", label: "Toggle theme", action: () => app.toggleTheme(), category: "Preferences" },
];
const filteredCommands = () => {
const q = query().toLowerCase();
if (!q) return commands;
return commands.filter(c =>
c.label.toLowerCase().includes(q) ||
c.category.toLowerCase().includes(q)
);
};
const handleKeyDown = (e: KeyboardEvent) => {
// Open with Cmd/Ctrl + K
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(!open());
setQuery("");
}
// Close with Escape
if (e.key === "Escape" && open()) {
setOpen(false);
}
};
onMount(() => {
window.addEventListener("keydown", handleKeyDown);
});
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown);
});
const executeCommand = (cmd: Command) => {
cmd.action();
setOpen(false);
setQuery("");
};
return (
<Show when={open()}>
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
{/* Palette */}
<div class="relative w-full max-w-xl bg-[var(--surface)] rounded-xl shadow-2xl border border-[var(--border)] overflow-hidden">
{/* Search input */}
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--border)]">
<Search class="w-5 h-5 text-[var(--text-muted)]" />
<input
type="text"
value={query()}
onInput={e => setQuery(e.currentTarget.value)}
placeholder="Search commands..."
class="flex-1 bg-transparent text-lg outline-none"
autofocus
/>
<kbd class="px-2 py-0.5 rounded bg-[var(--surface-hover)] text-xs text-[var(--text-muted)]">
ESC
</kbd>
</div>
{/* Commands list */}
<div class="max-h-[60vh] overflow-auto p-2">
<Show when={filteredCommands().length === 0}>
<div class="text-center py-8 text-[var(--text-muted)]">
No commands found
</div>
</Show>
<For each={Object.groupBy(filteredCommands(), c => c.category)}>
{([category, cmds]) => (
<div class="mb-2">
<div class="px-2 py-1 text-xs font-medium text-[var(--text-muted)] uppercase">
{category}
</div>
<For each={cmds}>
{cmd => (
<button
onClick={() => executeCommand(cmd)}
class="w-full flex items-center justify-between px-3 py-2 rounded-lg hover:bg-[var(--surface-hover)] text-left"
>
<span>{cmd.label}</span>
<Show when={cmd.shortcut}>
<kbd class="px-2 py-0.5 rounded bg-[var(--surface-hover)] text-xs text-[var(--text-muted)]">
{cmd.shortcut}
</kbd>
</Show>
</button>
)}
</For>
</div>
)}
</For>
</div>
{/* Footer */}
<div class="px-4 py-2 border-t border-[var(--border)] text-xs text-[var(--text-muted)] flex items-center gap-4">
<span><kbd class="px-1.5 py-0.5 rounded bg-[var(--surface-hover)]"></kbd> Navigate</span>
<span><kbd class="px-1.5 py-0.5 rounded bg-[var(--surface-hover)]"></kbd> Select</span>
<span><kbd class="px-1.5 py-0.5 rounded bg-[var(--surface-hover)]">ESC</kbd> Close</span>
</div>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,178 @@
import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { Search, X, CheckCircle, Calendar, FileText, User, Building2, Mail } from "lucide-solid";
import { useNavigate } from "@solidjs/router";
import { format, parseISO } from "date-fns";
import { useApp } from "~/lib/app-context";
interface SearchResult {
id: string;
type: "task" | "event" | "note" | "contact" | "company" | "email";
title: string;
subtitle?: string;
href: string;
}
export function GlobalSearch() {
const app = useApp();
const navigate = useNavigate();
const [open, setOpen] = createSignal(false);
const [query, setQuery] = createSignal("");
const workspaceSlug = () => app.primaryWorkspace()?.slug ?? "personal";
const results = (): SearchResult[] => {
const q = query().toLowerCase().trim();
if (!q || q.length < 2) return [];
const results: SearchResult[] = [];
// Search tasks
for (const task of app.state.tasks) {
if (task.workspaceSlug !== workspaceSlug()) continue;
if (task.title.toLowerCase().includes(q) || task.description.toLowerCase().includes(q)) {
results.push({
id: task.id,
type: "task",
title: task.title,
subtitle: task.status.replace("_", " "),
href: `/app/${workspaceSlug()}/board`
});
}
}
// Search events
for (const event of app.state.events) {
if (event.workspaceSlug !== workspaceSlug()) continue;
if (event.title.toLowerCase().includes(q)) {
results.push({
id: event.id,
type: "event",
title: event.title,
subtitle: format(parseISO(event.startsAt), "MMM d, yyyy"),
href: `/app/${workspaceSlug()}/calendar`
});
}
}
// Search notes
for (const note of app.state.notes) {
if (note.workspaceSlug !== workspaceSlug()) continue;
if (note.title.toLowerCase().includes(q) || note.content.toLowerCase().includes(q)) {
results.push({
id: note.id,
type: "note",
title: note.title || "Untitled",
subtitle: note.content.slice(0, 50) + "...",
href: `/app/${workspaceSlug()}/notes`
});
}
}
return results.slice(0, 10);
};
const handleKeyDown = (e: KeyboardEvent) => {
// Open with Cmd/Ctrl + /
if ((e.metaKey || e.ctrlKey) && e.key === "/") {
e.preventDefault();
setOpen(!open());
setQuery("");
}
if (e.key === "Escape" && open()) {
setOpen(false);
}
};
onMount(() => {
window.addEventListener("keydown", handleKeyDown);
});
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown);
});
const getIcon = (type: SearchResult["type"]) => {
switch (type) {
case "task": return CheckCircle;
case "event": return Calendar;
case "note": return FileText;
case "contact": return User;
case "company": return Building2;
case "email": return Mail;
}
};
const selectResult = (result: SearchResult) => {
navigate(result.href);
setOpen(false);
setQuery("");
};
return (
<Show when={open()}>
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
<div class="relative w-full max-w-xl bg-[var(--surface)] rounded-xl shadow-2xl border border-[var(--border)] overflow-hidden">
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--border)]">
<Search class="w-5 h-5 text-[var(--text-muted)]" />
<input
type="text"
value={query()}
onInput={e => setQuery(e.currentTarget.value)}
placeholder="Search tasks, events, notes..."
class="flex-1 bg-transparent text-lg outline-none"
autofocus
/>
<kbd class="px-2 py-0.5 rounded bg-[var(--surface-hover)] text-xs text-[var(--text-muted)]">
ESC
</kbd>
</div>
<div class="max-h-[50vh] overflow-auto p-2">
<Show when={query().length < 2}>
<div class="text-center py-8 text-[var(--text-muted)]">
Type at least 2 characters to search
</div>
</Show>
<Show when={query().length >= 2 && results().length === 0}>
<div class="text-center py-8 text-[var(--text-muted)]">
No results found for "{query()}"
</div>
</Show>
<For each={results()}>
{result => {
const Icon = getIcon(result.type);
return (
<button
onClick={() => selectResult(result)}
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-[var(--surface-hover)] text-left"
>
<Icon class="w-5 h-5 text-[var(--text-muted)]" />
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{result.title}</div>
<Show when={result.subtitle}>
<div class="text-sm text-[var(--text-muted)] truncate">
{result.subtitle}
</div>
</Show>
</div>
<span class="text-xs text-[var(--text-muted)] capitalize">
{result.type}
</span>
</button>
);
}}
</For>
</div>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,18 @@
import { createMemo } from "solid-js";
import { marked } from "marked";
interface MarkdownPreviewProps {
content: string;
}
export default function MarkdownPreview(props: MarkdownPreviewProps) {
const html = createMemo(() => marked.parse(props.content || ""));
return (
<div
class="prose prose-stone max-w-none text-sm leading-7 [&_h1]:text-2xl [&_h1]:font-extrabold [&_h2]:text-xl [&_h2]:font-bold [&_ul]:pl-5"
innerHTML={String(html())}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { Portal } from "solid-js/web";
import { Show, type JSX } from "solid-js";
import { X } from "lucide-solid";
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: JSX.Element;
}
export default function Modal(props: ModalProps) {
return (
<Show when={props.open}>
<Portal>
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{
background: 'rgba(0, 0, 0, 0.6)',
animation: 'fadeIn 0.2s ease-out'
}}
onClick={props.onClose}
>
<div
class="relative w-full max-w-lg rounded-2xl border border-[var(--border)] bg-[var(--surface)] shadow-2xl scale-in"
style={{
'box-shadow': '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.05)'
}}
onClick={e => e.stopPropagation()}
>
{/* Decorative gradient top border */}
<div class="absolute top-0 left-0 right-0 h-1 rounded-t-2xl bg-gradient-to-r from-[var(--accent)] via-[var(--secondary)] to-[var(--accent)] opacity-80" />
<div class="flex items-center justify-between border-b border-[var(--border)] px-6 py-4 bg-[var(--bg-subtle)]">
<h2 class="text-base font-semibold tracking-tight" style="font-family: var(--font-serif); font-variation-settings: 'opsz' 120;">
{props.title}
</h2>
<button
class="group flex h-9 w-9 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--bg-muted)] hover:text-[var(--text)] hover:scale-110 hover:rotate-90"
onClick={props.onClose}
aria-label="Close"
>
<X size={18} class="transition-transform duration-200" />
</button>
</div>
<div class="max-h-[calc(100vh-200px)] overflow-y-auto scrollbar-thin p-6">
{props.children}
</div>
</div>
</div>
</Portal>
</Show>
);
}
@@ -0,0 +1,159 @@
import { createSignal, For, Show, onMount, onCleanup } from "solid-js";
import { Bell, Check, CheckCheck, X } from "lucide-solid";
import {
apiListNotifications,
apiMarkNotificationRead,
apiMarkAllNotificationsRead,
apiUnreadNotificationCount
} from "~/lib/api-integrations";
import type { Notification } from "~/lib/types-integrations";
import { formatPrettyDate } from "~/lib/utils";
export function NotificationsDropdown() {
const [open, setOpen] = createSignal(false);
const [notifications, setNotifications] = createSignal<Notification[]>([]);
const [unreadCount, setUnreadCount] = createSignal(0);
const [loading, setLoading] = createSignal(false);
const loadNotifications = async () => {
setLoading(true);
try {
const [notifs, count] = await Promise.all([
apiListNotifications(),
apiUnreadNotificationCount()
]);
setNotifications(notifs);
setUnreadCount(count);
} catch (e) {
console.error("Failed to load notifications", e);
}
setLoading(false);
};
const markRead = async (id: string) => {
await apiMarkNotificationRead(id);
setNotifications(notifications().map(n => n.id === id ? { ...n, read: true } : n));
setUnreadCount(Math.max(0, unreadCount() - 1));
};
const markAllRead = async () => {
await apiMarkAllNotificationsRead();
setNotifications(notifications().map(n => ({ ...n, read: true })));
setUnreadCount(0);
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".notifications-dropdown")) {
setOpen(false);
}
};
onMount(() => {
loadNotifications();
document.addEventListener("click", handleClickOutside);
});
onCleanup(() => {
document.removeEventListener("click", handleClickOutside);
});
const getNotificationIcon = (type: string) => {
switch (type) {
case "task_assigned": return "📋";
case "task_completed": return "✅";
case "mention": return "💬";
case "comment": return "💭";
case "invite_accepted": return "👋";
default: return "🔔";
}
};
return (
<div class="relative notifications-dropdown">
<button
onClick={(e) => {
e.stopPropagation();
setOpen(!open());
if (!open()) return;
loadNotifications();
}}
class="relative flex h-11 w-11 items-center justify-center rounded-xl text-[var(--text-muted)] transition-all duration-200 hover:bg-[var(--bg-subtle)] hover:text-[var(--text)] hover:shadow-md hover:scale-105"
aria-label="Notifications"
>
<Bell size={19} />
<Show when={unreadCount() > 0}>
<span class="absolute -top-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--accent)] text-white text-xs font-bold">
{unreadCount() > 9 ? "9+" : unreadCount()}
</span>
</Show>
</button>
<Show when={open()}>
<div class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-xl z-50">
{/* Header */}
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<h3 class="font-semibold">Notifications</h3>
<Show when={unreadCount() > 0}>
<button
onClick={markAllRead}
class="flex items-center gap-1 text-xs text-[var(--accent)] hover:underline"
>
<CheckCheck size={14} />
Mark all read
</button>
</Show>
</div>
{/* List */}
<div class="max-h-96 overflow-y-auto">
<Show when={loading()}>
<div class="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
Loading...
</div>
</Show>
<Show when={!loading() && notifications().length === 0}>
<div class="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
No notifications
</div>
</Show>
<For each={notifications()}>
{notification => (
<div
class={`px-4 py-3 border-b border-[var(--border)] last:border-b-0 transition-colors ${
!notification.read ? "bg-[var(--accent-muted)]" : ""
}`}
>
<div class="flex items-start gap-3">
<span class="text-lg">{getNotificationIcon(notification.type)}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">{notification.title}</p>
<Show when={notification.body}>
<p class="text-xs text-[var(--text-muted)] mt-0.5 line-clamp-2">
{notification.body}
</p>
</Show>
<p class="text-xs text-[var(--text-subtle)] mt-1">
{formatPrettyDate(notification.createdAt)}
</p>
</div>
<Show when={!notification.read}>
<button
onClick={() => markRead(notification.id)}
class="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent)]"
title="Mark as read"
>
<Check size={14} />
</button>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
);
}
@@ -0,0 +1,135 @@
import { createSignal, For, Show, onMount, onCleanup, createEffect } from "solid-js";
import { apiListPresence, apiUpdatePresence, apiClearPresence } from "~/lib/api-integrations";
import type { Presence } from "~/lib/types-integrations";
import { useApp } from "~/lib/app-context";
interface PresenceIndicatorProps {
workspaceSlug: string;
entityType?: string;
entityId?: string;
refreshInterval?: number; // milliseconds, default 10000 (10 seconds)
}
export function PresenceIndicator(props: PresenceIndicatorProps) {
const app = useApp();
const [presences, setPresences] = createSignal<Presence[]>([]);
const [myPresence, setMyPresence] = createSignal<Presence | null>(null);
const refreshInterval = () => props.refreshInterval || 10000;
const loadPresence = async () => {
try {
const list = await apiListPresence(
props.workspaceSlug,
props.entityType || "",
props.entityId || ""
);
setPresences(list.filter(p => p.userEmail !== app.state.session?.email));
} catch (e) {
console.error("Failed to load presence", e);
}
};
const updateMyPresence = async () => {
if (!app.state.session) return;
try {
const presence = await apiUpdatePresence({
workspaceSlug: props.workspaceSlug,
userEmail: app.state.session.email,
userName: app.state.session.name,
entityType: props.entityType,
entityId: props.entityId
});
setMyPresence(presence);
} catch (e) {
console.error("Failed to update presence", e);
}
};
const clearMyPresence = async () => {
if (!app.state.session) return;
try {
await apiClearPresence(props.workspaceSlug);
} catch (e) {
console.error("Failed to clear presence", e);
}
};
// Update presence when entity changes
createEffect(() => {
const _ = props.entityId; // track dependency
const __ = props.entityType; // track dependency
updateMyPresence();
loadPresence();
});
onMount(() => {
updateMyPresence();
loadPresence();
// Refresh presence periodically for real-time updates
const interval = setInterval(() => {
loadPresence();
updateMyPresence(); // Keep our presence alive
}, refreshInterval());
onCleanup(() => {
clearInterval(interval);
clearMyPresence();
});
});
const getInitials = (name: string) => {
return name
.split(" ")
.map(n => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const getColor = (email: string) => {
const colors = [
"bg-red-500",
"bg-orange-500",
"bg-amber-500",
"bg-yellow-500",
"bg-lime-500",
"bg-green-500",
"bg-emerald-500",
"bg-teal-500",
"bg-cyan-500",
"bg-sky-500",
"bg-blue-500",
"bg-indigo-500",
"bg-violet-500",
"bg-purple-500",
"bg-fuchsia-500",
"bg-pink-500"
];
const hash = email.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
};
return (
<Show when={presences().length > 0}>
<div class="flex items-center gap-1">
<div class="flex -space-x-2">
<For each={presences().slice(0, 4)}>
{presence => (
<div
class={`w-7 h-7 rounded-full ${getColor(presence.userEmail)} flex items-center justify-center text-white text-xs font-medium ring-2 ring-[var(--surface)]`}
title={`${presence.userName} is viewing`}
>
{getInitials(presence.userName)}
</div>
)}
</For>
</div>
<Show when={presences().length > 4}>
<span class="text-xs text-[var(--text-muted)] ml-1">
+{presences().length - 4} more
</span>
</Show>
</div>
</Show>
);
}
@@ -0,0 +1,89 @@
import { createSignal, Show } from "solid-js";
import { Repeat } from "lucide-solid";
interface RecurringConfigProps {
value: string;
onChange: (rule: string) => void;
endDate?: string;
onEndDateChange: (date?: string) => void;
}
export function RecurringConfig(props: RecurringConfigProps) {
const [showOptions, setShowOptions] = createSignal(false);
const presets = [
{ label: "Daily", value: "FREQ=DAILY" },
{ label: "Weekdays", value: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" },
{ label: "Weekly", value: "FREQ=WEEKLY" },
{ label: "Bi-weekly", value: "FREQ=WEEKLY;INTERVAL=2" },
{ label: "Monthly", value: "FREQ=MONTHLY" },
{ label: "Quarterly", value: "FREQ=MONTHLY;INTERVAL=3" },
{ label: "Yearly", value: "FREQ=YEARLY" },
];
const getLabel = () => {
if (!props.value) return null;
const preset = presets.find(p => p.value === props.value);
return preset?.label || "Custom";
};
return (
<div class="relative">
<button
type="button"
onClick={() => setShowOptions(!showOptions())}
class={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-colors ${
props.value
? "border-[var(--accent)] text-[var(--accent)] bg-[var(--accent-muted)]"
: "border-[var(--border)] text-[var(--text-muted)] hover:bg-[var(--surface-hover)]"
}`}
>
<Repeat class="w-4 h-4" />
<Show when={getLabel()} fallback="Repeat">
{getLabel()}
</Show>
</button>
<Show when={showOptions()}>
<div class="absolute top-full left-0 mt-1 z-10 w-64 p-3 rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
<div class="space-y-1">
<button
type="button"
onClick={() => { props.onChange(""); setShowOptions(false); }}
class={`w-full text-left px-3 py-2 rounded-lg text-sm ${
!props.value ? "bg-[var(--accent-muted)] text-[var(--accent)]" : "hover:bg-[var(--surface-hover)]"
}`}
>
No repeat
</button>
{presets.map(preset => (
<button
type="button"
onClick={() => { props.onChange(preset.value); setShowOptions(false); }}
class={`w-full text-left px-3 py-2 rounded-lg text-sm ${
props.value === preset.value ? "bg-[var(--accent-muted)] text-[var(--accent)]" : "hover:bg-[var(--surface-hover)]"
}`}
>
{preset.label}
</button>
))}
</div>
<Show when={props.value}>
<div class="mt-3 pt-3 border-t border-[var(--border)]">
<label class="block text-xs font-medium text-[var(--text-muted)] mb-1">
End date (optional)
</label>
<input
type="date"
value={props.endDate || ""}
onInput={e => props.onEndDateChange(e.currentTarget.value || undefined)}
class="w-full px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] text-sm"
/>
</div>
</Show>
</div>
</Show>
</div>
);
}
@@ -0,0 +1,324 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { Clock, Link, Repeat, Trash2, Users } from "lucide-solid";
import Modal from "~/components/modal";
import { TimeTracker } from "./time-tracker";
import { RecurringConfig } from "./recurring-config";
import { useApp } from "~/lib/app-context";
import { colorOptions, getColorStyle } from "~/lib/utils";
import { apiLinkContactToTask, apiUnlinkContactFromTask } from "~/lib/api-crm";
import { notifyMentionedUsers, hasMentions } from "~/lib/mentions";
import type { Task } from "~/lib/types";
import type { Contact } from "~/lib/types-crm";
interface TaskDetailModalProps {
task: Task | undefined;
workspaceSlug: string;
labels: { id: string; name: string; color: string }[];
contacts?: Contact[];
onClose: () => void;
onDelete?: (taskId: string) => void;
}
export function TaskDetailModal(props: TaskDetailModalProps) {
const app = useApp();
const [newComment, setNewComment] = createSignal("");
const [recurrenceRule, setRecurrenceRule] = createSignal(props.task?.recurrenceRule || "");
const [recurrenceEnd, setRecurrenceEnd] = createSignal<string | undefined>(undefined);
const [showContacts, setShowContacts] = createSignal(false);
const [selectedContactId, setSelectedContactId] = createSignal("");
const task = () => props.task;
const updateTask = (updates: Partial<Task>) => {
if (!task()) return;
app.updateTask(task()!.id, current => {
Object.assign(current, updates);
});
};
const handleAddComment = async () => {
if (!newComment().trim() || !task()) return;
const commentContent = newComment();
app.createTaskComment(task()!.id, commentContent);
// Check for @mentions and create notifications
if (hasMentions(commentContent) && app.state.session) {
const workspaceSlug = task()!.workspaceSlug;
const memberEmails = new Map(
app.state.members.map(m => [m.name?.toLowerCase() || '', m.email])
);
await notifyMentionedUsers(
workspaceSlug,
app.state.session.name,
"task",
task()!.id,
commentContent,
memberEmails
);
}
setNewComment("");
};
const handleLinkContact = async () => {
if (!selectedContactId() || !task()) return;
try {
await apiLinkContactToTask(selectedContactId(), task()!.id);
// Refresh contacts list would happen here
} catch (e) {
console.error("Failed to link contact", e);
}
setSelectedContactId("");
setShowContacts(false);
};
return (
<Modal
open={!!task()}
title={task()?.title ?? "Task"}
onClose={props.onClose}
>
<Show when={task()}>
<div class="space-y-6">
{/* Title */}
<div>
<label class="mb-1.5 block text-sm font-medium">Title</label>
<input
class="input-base w-full"
value={task()!.title}
onInput={e => updateTask({ title: e.currentTarget.value })}
/>
</div>
{/* Description */}
<div>
<label class="mb-1.5 block text-sm font-medium">Description</label>
<textarea
class="input-base min-h-24 resize-none w-full"
value={task()!.description}
onInput={e => updateTask({ description: e.currentTarget.value })}
/>
</div>
{/* Due date and Color */}
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium">Due date</label>
<input
class="input-base w-full"
type="datetime-local"
value={task()!.dueAt?.slice(0, 16) ?? ""}
onInput={e => updateTask({
dueAt: e.currentTarget.value ? new Date(e.currentTarget.value).toISOString() : undefined
})}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium">Color</label>
<select
class="input-base w-full"
value={task()!.color}
onInput={e => updateTask({ color: e.currentTarget.value })}
>
<For each={colorOptions}>
{option => <option value={option}>{option}</option>}
</For>
</select>
</div>
</div>
{/* Recurring */}
<div>
<div class="flex items-center gap-2 mb-2">
<Repeat class="w-4 h-4 text-[var(--text-muted)]" />
<label class="text-sm font-medium">Recurring</label>
</div>
<RecurringConfig
value={task()!.recurrenceRule || ""}
onChange={(rule) => {
updateTask({ recurrenceRule: rule });
}}
endDate={task()!.recurrenceEnd}
onEndDateChange={(date) => {
updateTask({ recurrenceEnd: date });
}}
/>
</div>
{/* Labels */}
<div>
<label class="mb-2 block text-sm font-medium">Labels</label>
<div class="flex flex-wrap gap-2">
<For each={props.labels}>
{label => {
const active = () => task()!.labelIds.includes(label.id);
return (
<button
class="rounded px-2 py-1 text-xs font-medium transition-colors"
classList={{ "bg-[var(--bg-muted)]": !active() }}
style={{
background: active() ? getColorStyle(label.color).bg : undefined,
color: active() ? getColorStyle(label.color).text : "var(--text-muted)"
}}
onClick={() => updateTask({
labelIds: active()
? task()!.labelIds.filter(id => id !== label.id)
: [...task()!.labelIds, label.id]
})}
>
{label.name}
</button>
);
}}
</For>
</div>
</div>
{/* Time Tracking */}
<div>
<div class="flex items-center gap-2 mb-2">
<Clock class="w-4 h-4 text-[var(--text-muted)]" />
<label class="text-sm font-medium">Time Tracking</label>
</div>
<TimeTracker
taskId={task()!.id}
workspaceSlug={props.workspaceSlug}
/>
</div>
{/* Contacts */}
<Show when={props.contacts && props.contacts.length > 0}>
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<Users class="w-4 h-4 text-[var(--text-muted)]" />
<label class="text-sm font-medium">Linked Contacts</label>
</div>
<button
onClick={() => setShowContacts(!showContacts())}
class="text-xs text-[var(--accent)] hover:underline"
>
+ Add contact
</button>
</div>
<Show when={showContacts()}>
<div class="flex gap-2 mb-2">
<select
class="input-base flex-1 text-sm"
value={selectedContactId()}
onChange={e => setSelectedContactId(e.currentTarget.value)}
>
<option value="">Select contact...</option>
<For each={props.contacts}>
{contact => <option value={contact.id}>{contact.firstName} {contact.lastName}</option>}
</For>
</select>
<button
onClick={handleLinkContact}
class="button-secondary px-3 py-1.5 text-sm"
disabled={!selectedContactId()}
>
Link
</button>
</div>
</Show>
</div>
</Show>
{/* Attachments */}
<div>
<label class="mb-1.5 block text-sm font-medium">Attachments</label>
<input
class="input-base w-full"
type="file"
multiple
onChange={e => app.attachFilesToTask(task()!.id, e.currentTarget.files)}
/>
<Show when={task()!.attachments.length > 0}>
<div class="mt-2 space-y-1">
<For each={task()!.attachments}>
{attachment => (
<div class="flex items-center justify-between gap-2 rounded-md bg-[var(--bg-subtle)] px-3 py-2 text-sm">
<a
class="min-w-0 flex-1 truncate"
href={attachment.dataUrl}
download={attachment.name}
>
{attachment.name}
</a>
<span class="text-xs text-[var(--text-muted)]">
{Math.round(attachment.size / 1024)} KB
</span>
<button
class="flex h-6 w-6 items-center justify-center rounded text-[var(--text-muted)] transition-colors hover:bg-[var(--bg-muted)] hover:text-[var(--error)]"
onClick={() => app.removeTaskAttachment(task()!.id, attachment.id)}
>
<Trash2 size={14} />
</button>
</div>
)}
</For>
</div>
</Show>
</div>
{/* Comments */}
<div>
<label class="mb-2 block text-sm font-medium">Comments</label>
<div class="mb-2 space-y-2 max-h-40 overflow-y-auto">
<For each={task()!.comments}>
{comment => (
<div class="rounded-md bg-[var(--bg-subtle)] px-3 py-2">
<p class="text-xs font-medium">{comment.author}</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">{comment.content}</p>
</div>
)}
</For>
</div>
<div class="flex gap-2">
<input
class="input-base flex-1"
placeholder="Add a comment..."
value={newComment()}
onInput={e => setNewComment(e.currentTarget.value)}
onKeyDown={e => e.key === "Enter" && handleAddComment()}
/>
<button
class="button-secondary px-3 py-2 text-sm"
onClick={handleAddComment}
disabled={!newComment().trim()}
>
Send
</button>
</div>
</div>
{/* Actions */}
<div class="flex justify-between pt-4 border-t border-[var(--border)]">
<Show when={props.onDelete}>
<button
onClick={() => {
props.onDelete?.(task()!.id);
props.onClose();
}}
class="px-4 py-2 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-950 text-sm"
>
Delete Task
</button>
</Show>
<div class="flex gap-2 ml-auto">
<button
onClick={props.onClose}
class="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface-hover)] text-sm"
>
Close
</button>
</div>
</div>
</div>
</Show>
</Modal>
);
}
@@ -0,0 +1,168 @@
import { createSignal, createMemo, For, Show, onCleanup, onMount } from "solid-js";
import { Play, Pause, Square, Clock } from "lucide-solid";
import { format, parseISO, differenceInSeconds } from "date-fns";
import { useApp } from "~/lib/app-context";
import {
apiListTimeEntries,
apiCreateTimeEntry,
apiUpdateTimeEntry,
apiDeleteTimeEntry
} from "~/lib/api-crm";
import type { TimeEntry } from "~/lib/types-crm";
interface TimeTrackerProps {
taskId?: string;
workspaceSlug: string;
}
export function TimeTracker(props: TimeTrackerProps) {
const app = useApp();
const [entries, setEntries] = createSignal<TimeEntry[]>([]);
const [activeEntry, setActiveEntry] = createSignal<TimeEntry | null>(null);
const [now, setNow] = createSignal(Date.now());
const [description, setDescription] = createSignal("");
let timer: number | null = null;
onMount(() => {
timer = window.setInterval(() => setNow(Date.now()), 1000);
loadEntries();
});
onCleanup(() => {
if (timer) window.clearInterval(timer);
});
const loadEntries = async () => {
try {
const list = await apiListTimeEntries(props.workspaceSlug);
setEntries(list.filter(e => !props.taskId || e.taskId === props.taskId));
// Find active entry (no end time)
const active = list.find(e => !e.endedAt && (!props.taskId || e.taskId === props.taskId));
if (active) {
setActiveEntry(active);
setDescription(active.description);
}
} catch (e) {
console.error("Failed to load time entries", e);
}
};
const startTimer = async () => {
const entry = await apiCreateTimeEntry({
workspaceSlug: props.workspaceSlug,
taskId: props.taskId,
description: description(),
startedAt: new Date().toISOString()
});
setActiveEntry(entry);
setEntries([entry, ...entries()]);
};
const stopTimer = async () => {
const entry = activeEntry();
if (!entry) return;
const updated = await apiUpdateTimeEntry(entry.id, {
endedAt: new Date().toISOString()
});
setActiveEntry(null);
setEntries(entries().map(e => e.id === updated.id ? updated : e));
};
const deleteEntry = async (id: string) => {
await apiDeleteTimeEntry(id);
setEntries(entries().filter(e => e.id !== id));
};
const elapsedSeconds = createMemo(() => {
const entry = activeEntry();
if (!entry) return 0;
return differenceInSeconds(new Date(), parseISO(entry.startedAt));
});
const formatDuration = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
};
const totalTime = createMemo(() => {
return entries()
.filter(e => e.endedAt)
.reduce((sum, e) => sum + e.durationSeconds, 0);
});
return (
<div class="space-y-4">
{/* Active timer */}
<div class="flex items-center gap-4 p-4 rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<Show when={activeEntry()} fallback={
<button
onClick={startTimer}
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90"
>
<Play class="w-4 h-4" />
Start Timer
</button>
}>
<button
onClick={stopTimer}
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500 text-white hover:opacity-90"
>
<Square class="w-4 h-4" />
Stop
</button>
<div class="flex-1">
<div class="text-2xl font-mono">{formatDuration(elapsedSeconds())}</div>
<input
type="text"
value={description()}
onInput={e => setDescription(e.currentTarget.value)}
placeholder="What are you working on?"
class="w-full bg-transparent text-sm text-[var(--text-muted)] outline-none"
/>
</div>
</Show>
</div>
{/* Total time */}
<div class="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Clock class="w-4 h-4" />
Total logged: {formatDuration(totalTime())}
</div>
{/* Time entries list */}
<Show when={entries().length > 0}>
<div class="space-y-2">
<h4 class="text-sm font-medium">Time Log</h4>
<For each={entries().filter(e => e.endedAt).slice(0, 5)}>
{entry => (
<div class="flex items-center justify-between p-3 rounded-lg border border-[var(--border)] bg-[var(--surface)]">
<div>
<div class="text-sm">{entry.description || "No description"}</div>
<div class="text-xs text-[var(--text-muted)]">
{format(parseISO(entry.startedAt), "MMM d, h:mm a")} {formatDuration(entry.durationSeconds)}
</div>
</div>
<button
onClick={() => deleteEntry(entry.id)}
class="text-[var(--text-muted)] hover:text-red-500"
>
<Square class="w-4 h-4" />
</button>
</div>
)}
</For>
</div>
</Show>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />
+26
View File
@@ -0,0 +1,26 @@
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./app";
import "./app.css";
// Service Worker registration
if ("serviceWorker" in navigator) {
if (import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => undefined);
});
} else {
void navigator.serviceWorker
.getRegistrations()
.then(registrations => Promise.all(registrations.map(registration => registration.unregister())))
.catch(() => undefined);
}
}
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
render(() => <App />, root);
+53
View File
@@ -0,0 +1,53 @@
import type { ActivityEntry } from "./types";
export type ActivityType = "all" | "task" | "board" | "calendar" | "note" | "focus" | "mail" | "invite" | "system";
const activityTypeLabels: Record<ActivityType, string> = {
all: "All",
task: "Tasks",
board: "Board",
calendar: "Calendar",
note: "Notes",
focus: "Focus",
mail: "Mail",
invite: "Invites",
system: "System"
};
export function listActivityTypeOptions(): Array<{ value: ActivityType; label: string }> {
return (Object.keys(activityTypeLabels) as ActivityType[]).map(value => ({
value,
label: activityTypeLabels[value]
}));
}
export function classifyActivity(entry: ActivityEntry): Exclude<ActivityType, "all"> {
const text = `${entry.title} ${entry.detail}`.toLowerCase().trim();
if (text.includes("invite")) {
return "invite";
}
if (text.includes("board")) {
return "board";
}
if (text.includes("mail") || text.includes("inbox") || text.includes("smtp") || text.includes("imap")) {
return "mail";
}
if (text.includes("calendar") || text.includes("event")) {
return "calendar";
}
if (text.includes("note")) {
return "note";
}
if (text.includes("focus") || text.includes("pomodoro")) {
return "focus";
}
if (text.includes("task")) {
return "task";
}
return "system";
}
export function getActivityTypeLabel(type: Exclude<ActivityType, "all">): string {
return activityTypeLabels[type];
}
+171
View File
@@ -0,0 +1,171 @@
import { apiFetch } from "./api";
import type {
Contact,
Company,
InboxItem,
TimeEntry,
SavedView,
CreateContactInput,
UpdateContactInput,
CreateCompanyInput,
UpdateCompanyInput,
CreateInboxItemInput,
CreateTimeEntryInput,
UpdateTimeEntryInput,
CreateSavedViewInput
} from "./types-crm";
// Contacts
export async function apiListContacts(workspaceSlug: string): Promise<Contact[]> {
const res = await apiFetch(`/v1/contacts?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateContact(input: CreateContactInput): Promise<Contact> {
const res = await apiFetch("/v1/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiUpdateContact(contactId: string, input: UpdateContactInput): Promise<Contact> {
const res = await apiFetch(`/v1/contacts/${contactId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteContact(contactId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}`, { method: "DELETE" });
}
export async function apiLinkContactToTask(contactId: string, taskId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}/tasks/${taskId}`, { method: "POST" });
}
export async function apiUnlinkContactFromTask(contactId: string, taskId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}/tasks/${taskId}`, { method: "DELETE" });
}
export async function apiLinkContactToEvent(contactId: string, eventId: string): Promise<void> {
await apiFetch(`/v1/contacts/${contactId}/events/${eventId}`, { method: "POST" });
}
// Companies
export async function apiListCompanies(workspaceSlug: string): Promise<Company[]> {
const res = await apiFetch(`/v1/companies?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateCompany(input: CreateCompanyInput): Promise<Company> {
const res = await apiFetch("/v1/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiUpdateCompany(companyId: string, input: UpdateCompanyInput): Promise<Company> {
const res = await apiFetch(`/v1/companies/${companyId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteCompany(companyId: string): Promise<void> {
await apiFetch(`/v1/companies/${companyId}`, { method: "DELETE" });
}
// Inbox
export async function apiListInboxItems(workspaceSlug: string): Promise<InboxItem[]> {
const res = await apiFetch(`/v1/inbox?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateInboxItem(input: CreateInboxItemInput): Promise<InboxItem> {
const res = await apiFetch("/v1/inbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiProcessInboxItem(itemId: string, entityType: string, entityId: string): Promise<void> {
await apiFetch(`/v1/inbox/${itemId}/process`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entityType, entityId })
});
}
export async function apiDeleteInboxItem(itemId: string): Promise<void> {
await apiFetch(`/v1/inbox/${itemId}`, { method: "DELETE" });
}
// Time entries
export async function apiListTimeEntries(workspaceSlug: string): Promise<TimeEntry[]> {
const res = await apiFetch(`/v1/time-entries?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateTimeEntry(input: CreateTimeEntryInput): Promise<TimeEntry> {
const res = await apiFetch("/v1/time-entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiUpdateTimeEntry(entryId: string, input: UpdateTimeEntryInput): Promise<TimeEntry> {
const res = await apiFetch(`/v1/time-entries/${entryId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteTimeEntry(entryId: string): Promise<void> {
await apiFetch(`/v1/time-entries/${entryId}`, { method: "DELETE" });
}
// Saved views
export async function apiListSavedViews(workspaceSlug: string, entityType: string): Promise<SavedView[]> {
const res = await apiFetch(`/v1/saved-views?workspaceSlug=${encodeURIComponent(workspaceSlug)}&entityType=${encodeURIComponent(entityType)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateSavedView(input: CreateSavedViewInput): Promise<SavedView> {
const res = await apiFetch("/v1/saved-views", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteSavedView(viewId: string): Promise<void> {
await apiFetch(`/v1/saved-views/${viewId}`, { method: "DELETE" });
}
+131
View File
@@ -0,0 +1,131 @@
import { apiFetch } from "./api";
import type {
Integration,
Webhook,
Notification,
Presence,
CreateIntegrationInput,
CreateWebhookInput,
UpdatePresenceInput,
CreateNotificationInput
} from "./types-integrations";
// Integrations
export async function apiListIntegrations(workspaceSlug: string): Promise<Integration[]> {
const res = await apiFetch(`/v1/integrations?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateIntegration(input: CreateIntegrationInput): Promise<Integration> {
const res = await apiFetch("/v1/integrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiDeleteIntegration(integrationId: string): Promise<void> {
await apiFetch(`/v1/integrations/${integrationId}`, { method: "DELETE" });
}
// Webhooks
export async function apiListWebhooks(workspaceSlug: string): Promise<Webhook[]> {
const res = await apiFetch(`/v1/webhooks?workspaceSlug=${encodeURIComponent(workspaceSlug)}`);
const data = await res.json();
return data.data || [];
}
export async function apiCreateWebhook(input: CreateWebhookInput): Promise<Webhook> {
const res = await apiFetch("/v1/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...input, events: JSON.stringify(input.events || []) })
});
const data = await res.json();
return data.data;
}
export async function apiDeleteWebhook(webhookId: string): Promise<void> {
await apiFetch(`/v1/webhooks/${webhookId}`, { method: "DELETE" });
}
// Notifications
export async function apiListNotifications(): Promise<Notification[]> {
const res = await apiFetch("/v1/notifications");
const data = await res.json();
return data.data || [];
}
export async function apiMarkNotificationRead(notificationId: string): Promise<void> {
await apiFetch(`/v1/notifications/${notificationId}/read`, { method: "POST" });
}
export async function apiMarkAllNotificationsRead(): Promise<void> {
await apiFetch("/v1/notifications/read-all", { method: "POST" });
}
export async function apiUnreadNotificationCount(): Promise<number> {
const res = await apiFetch("/v1/notifications/unread-count");
const data = await res.json();
return data.count || 0;
}
// Presence
export async function apiUpdatePresence(input: UpdatePresenceInput): Promise<Presence> {
const res = await apiFetch("/v1/presence", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
export async function apiListPresence(workspaceSlug: string, entityType?: string, entityId?: string): Promise<Presence[]> {
let url = `/v1/presence?workspaceSlug=${encodeURIComponent(workspaceSlug)}`;
if (entityType) url += `&entityType=${encodeURIComponent(entityType)}`;
if (entityId) url += `&entityId=${encodeURIComponent(entityId)}`;
const res = await apiFetch(url);
const data = await res.json();
return data.data || [];
}
export async function apiClearPresence(workspaceSlug: string): Promise<void> {
await apiFetch(`/v1/presence?workspaceSlug=${encodeURIComponent(workspaceSlug)}`, { method: "DELETE" });
}
// Create notification
export async function apiCreateNotification(input: CreateNotificationInput): Promise<Notification> {
const res = await apiFetch("/v1/notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input)
});
const data = await res.json();
return data.data;
}
// OAuth
export async function apiConnectGoogleCalendar(workspaceSlug: string, redirect?: string): Promise<OAuthConnectResponse> {
const params = new URLSearchParams({ workspaceSlug });
if (redirect) params.append("redirect", redirect);
const res = await apiFetch(`/v1/oauth/google-calendar/connect?${params.toString()}`);
const data = await res.json();
return data;
}
export async function apiConnectSlack(workspaceSlug: string, redirect?: string): Promise<OAuthConnectResponse> {
const params = new URLSearchParams({ workspaceSlug });
if (redirect) params.append("redirect", redirect);
const res = await apiFetch(`/v1/oauth/slack/connect?${params.toString()}`);
const data = await res.json();
return data;
}
export async function apiDisconnectIntegration(integrationId: string): Promise<void> {
await apiFetch(`/v1/integrations/${integrationId}/disconnect`, { method: "POST" });
}
import type { OAuthConnectResponse } from "./types-integrations";
+294
View File
@@ -0,0 +1,294 @@
import {
acceptInvite as acceptInviteRequest,
connectMailbox as connectMailboxRequest,
createBoardGroup as createBoardGroupRequest,
createCalendarEvent,
createClient,
createFocusSession as createFocusSessionRequest,
createInvite as createInviteRequest,
createLabel as createLabelRequest,
createNote as createNoteRequest,
createOutgoingMail as createOutgoingMailRequest,
createTask as createTaskRequest,
createTaskFromMail as createTaskFromMailRequest,
deleteTaskAttachment as deleteTaskAttachmentRequest,
getInviteByToken as getInviteByTokenRequest,
listActivity,
listBoardGroups,
listCalendarEvents,
listFocusSessions,
listInvites,
listLabels,
listMailMessages as listMailMessagesRequest,
listMailboxes as listMailboxesRequest,
listMembers,
listNotes,
listOutgoingMails as listOutgoingMailsRequest,
listTasks,
listWorkspaces,
revokeInvite as revokeInviteRequest,
syncMailbox as syncMailboxRequest,
uploadTaskAttachment as uploadTaskAttachmentRequest,
updateBoardGroup as updateBoardGroupRequest,
updateCalendarEvent,
updateFocusSession as updateFocusSessionRequest,
updateMember as updateMemberRequest,
updateNote as updateNoteRequest,
updateTask as updateTaskRequest
} from "@productier/api-client";
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:48080";
const apiClient = createClient({
baseUrl: API_BASE_URL
});
// Helper for custom API routes not in the generated client
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
...init?.headers
}
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res;
}
export async function fetchWorkspaceBundle(workspaceSlug: string) {
const [workspaces, members, invites, boardGroups, labels, tasks, events, notes, focusSessions, activity] = await Promise.all([
listWorkspaces({ client: apiClient, responseStyle: "data" }),
listMembers({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listInvites({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listBoardGroups({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listLabels({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listTasks({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listCalendarEvents({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listNotes({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listFocusSessions({ client: apiClient, responseStyle: "data", query: { workspaceSlug } }),
listActivity({ client: apiClient, responseStyle: "data", query: { workspaceSlug, limit: 40 } })
]);
return {
workspaces: workspaces?.data ?? [],
members: members?.data ?? [],
invites: invites?.data ?? [],
boardGroups: boardGroups?.data ?? [],
labels: labels?.data ?? [],
tasks: tasks?.data ?? [],
events: events?.data ?? [],
notes: notes?.data ?? [],
focusSessions: focusSessions?.data ?? [],
activities: activity?.data ?? []
};
}
export async function apiGetInviteByToken(token: string) {
const response = await getInviteByTokenRequest({
client: apiClient,
responseStyle: "data",
path: { token }
});
return response?.data;
}
export async function apiCreateInvite(body: Parameters<typeof createInviteRequest>[0]["body"]) {
const response = await createInviteRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiRevokeInvite(inviteId: string) {
await revokeInviteRequest({
client: apiClient,
responseStyle: "data",
path: { inviteId }
});
}
export async function apiAcceptInvite(token: string, body: Parameters<typeof acceptInviteRequest>[0]["body"]) {
const response = await acceptInviteRequest({
client: apiClient,
responseStyle: "data",
path: { token },
body
});
return response?.data;
}
export async function apiCreateTask(body: Parameters<typeof createTaskRequest>[0]["body"]) {
const response = await createTaskRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateTask(taskId: string, body: Parameters<typeof updateTaskRequest>[0]["body"]) {
const response = await updateTaskRequest({
client: apiClient,
responseStyle: "data",
path: { taskId },
body
});
return response?.data;
}
export async function apiUpdateMember(memberId: string, body: Parameters<typeof updateMemberRequest>[0]["body"]) {
const response = await updateMemberRequest({
client: apiClient,
responseStyle: "data",
path: { memberId },
body
});
return response?.data;
}
export async function apiCreateEvent(body: Parameters<typeof createCalendarEvent>[0]["body"]) {
const response = await createCalendarEvent({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateEvent(eventId: string, body: Parameters<typeof updateCalendarEvent>[0]["body"]) {
const response = await updateCalendarEvent({
client: apiClient,
responseStyle: "data",
path: { eventId },
body
});
return response?.data;
}
export async function apiCreateNote(body: Parameters<typeof createNoteRequest>[0]["body"]) {
const response = await createNoteRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateNote(noteId: string, body: Parameters<typeof updateNoteRequest>[0]["body"]) {
const response = await updateNoteRequest({
client: apiClient,
responseStyle: "data",
path: { noteId },
body
});
return response?.data;
}
export async function apiCreateBoardGroup(body: Parameters<typeof createBoardGroupRequest>[0]["body"]) {
const response = await createBoardGroupRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateBoardGroup(groupId: string, body: Parameters<typeof updateBoardGroupRequest>[0]["body"]) {
const response = await updateBoardGroupRequest({
client: apiClient,
responseStyle: "data",
path: { groupId },
body
});
return response?.data;
}
export async function apiUploadTaskAttachment(taskId: string, file: File) {
const formData = new FormData();
formData.set("file", file);
const response = await uploadTaskAttachmentRequest({
client: apiClient,
responseStyle: "data",
path: { taskId },
body: formData
});
return response?.data;
}
export async function apiDeleteTaskAttachment(taskId: string, attachmentId: string) {
await deleteTaskAttachmentRequest({
client: apiClient,
responseStyle: "data",
path: { taskId, attachmentId }
});
}
export async function apiCreateLabel(body: Parameters<typeof createLabelRequest>[0]["body"]) {
const response = await createLabelRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiCreateFocusSession(body: Parameters<typeof createFocusSessionRequest>[0]["body"]) {
const response = await createFocusSessionRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiUpdateFocusSession(sessionId: string, body: Parameters<typeof updateFocusSessionRequest>[0]["body"]) {
const response = await updateFocusSessionRequest({
client: apiClient,
responseStyle: "data",
path: { sessionId },
body
});
return response?.data;
}
export async function apiListMailboxes(workspaceSlug: string) {
const response = await listMailboxesRequest({
client: apiClient,
responseStyle: "data",
query: { workspaceSlug }
});
return response?.data ?? [];
}
export async function apiConnectMailbox(body: Parameters<typeof connectMailboxRequest>[0]["body"]) {
const response = await connectMailboxRequest({ client: apiClient, responseStyle: "data", body });
return response?.data;
}
export async function apiSyncMailbox(mailboxId: string) {
const response = await syncMailboxRequest({
client: apiClient,
responseStyle: "data",
path: { mailboxId }
});
return response?.data;
}
export async function apiListMailMessages(workspaceSlug: string, mailboxId?: string) {
const response = await listMailMessagesRequest({
client: apiClient,
responseStyle: "data",
query: {
workspaceSlug,
...(mailboxId ? { mailboxId } : {})
}
});
return response?.data ?? [];
}
export async function apiListOutgoingMails(workspaceSlug: string, mailboxId?: string) {
const response = await listOutgoingMailsRequest({
client: apiClient,
responseStyle: "data",
query: {
workspaceSlug,
...(mailboxId ? { mailboxId } : {})
}
});
return response?.data ?? [];
}
export async function apiCreateOutgoingMail(body: Parameters<typeof createOutgoingMailRequest>[0]["body"]) {
const response = await createOutgoingMailRequest({
client: apiClient,
responseStyle: "data",
body
});
return response?.data;
}
export async function apiCreateTaskFromMail(messageId: string, body: Parameters<typeof createTaskFromMailRequest>[0]["body"]) {
const response = await createTaskFromMailRequest({
client: apiClient,
responseStyle: "data",
path: { messageId },
body
});
return response?.data;
}

Some files were not shown because too many files have changed in this diff Show More