mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 12:33:01 +00:00
first commit
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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"]
|
||||||
Executable
BIN
Binary file not shown.
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -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;"]
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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("/"));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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())}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@solidjs/start/env" />
|
||||||
@@ -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);
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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
Reference in New Issue
Block a user