mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-03 20:13: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