first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags='-s -w' -o /out/api ./cmd/api
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S app \
&& adduser -S -G app app
WORKDIR /app
COPY --from=build /out/api /app/api
COPY internal/db/migrations /app/migrations
ENV APP_ENV=production
ENV API_PORT=8080
ENV DB_MIGRATIONS_DIR=/app/migrations
EXPOSE 8080
USER app
ENTRYPOINT ["/app/api"]
BIN
View File
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS runtime
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-fund --no-audit
COPY src ./src
ENV NODE_ENV=production
ENV AUTH_PORT=3001
EXPOSE 3001
USER node
CMD ["node", "src/server.mjs"]
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@productier/auth-service",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/server.mjs",
"start": "node src/server.mjs",
"check": "node --check src/server.mjs && node --check src/auth.mjs"
},
"dependencies": {
"better-auth": "^1.5.6",
"kysely": "^0.28.14",
"nodemailer": "^6.10.1",
"pg": "^8.20.0"
}
}
+311
View File
@@ -0,0 +1,311 @@
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
import { Kysely, PostgresDialect } from "kysely";
import nodemailer from "nodemailer";
import { Pool } from "pg";
import { recordDevMail } from "./dev-mailbox.mjs";
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const authUrl = process.env.AUTH_URL || "http://localhost:3001";
const databaseUrl = process.env.DATABASE_URL || "postgres://productier:productier@localhost:5432/productier?sslmode=disable";
const authSecret = process.env.BETTER_AUTH_SECRET || "replace-me-with-a-long-random-secret";
const appMode = (process.env.APP_ENV || process.env.NODE_ENV || "development").trim().toLowerCase();
const productionLikeMode = appMode === "staging" || appMode === "production";
const magicLinkProvider = resolveMagicLinkProvider(process.env.AUTH_MAGIC_LINK_PROVIDER, productionLikeMode);
export const devMailboxEnabled = resolveDevMailboxEnabled(process.env.AUTH_DEV_MAILBOX_ENABLED, appMode);
const smtpConfig = magicLinkProvider === "smtp" ? loadSMTPConfig() : null;
validateRuntimeConfig();
const magicLinkTransport = createMagicLinkTransport();
const pool = new Pool({
connectionString: databaseUrl
});
const db = new Kysely({
dialect: new PostgresDialect({
pool
})
});
export const auth = betterAuth({
appName: "Productier",
baseURL: authUrl,
secret: authSecret,
trustedOrigins: [frontendUrl, authUrl],
database: {
db,
type: "postgres"
},
emailAndPassword: {
enabled: true,
autoSignIn: true
},
user: {
changeEmail: {
enabled: false
}
},
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await magicLinkTransport.send({
email,
url
});
}
})
]
});
let authReadyPromise = null;
export async function ensureAuthReady() {
if (!authReadyPromise) {
authReadyPromise = auth.$context
.then(async context => {
await context.runMigrations();
await magicLinkTransport.verify();
})
.catch(error => {
authReadyPromise = null;
throw error;
});
}
return authReadyPromise;
}
export async function closeAuth() {
await magicLinkTransport.close();
await pool.end();
}
function validateRuntimeConfig() {
assertHTTPURL(frontendUrl, "FRONTEND_URL");
assertHTTPURL(authUrl, "AUTH_URL");
assertAbsoluteURL(databaseUrl, "DATABASE_URL");
if (!productionLikeMode) {
return;
}
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL must be explicitly set in staging/production");
}
if (isWeakSecret(authSecret)) {
throw new Error("BETTER_AUTH_SECRET must be set to a strong non-placeholder value in staging/production");
}
assertSecurePublicURL(frontendUrl, "FRONTEND_URL");
assertSecurePublicURL(authUrl, "AUTH_URL");
if (magicLinkProvider !== "smtp") {
throw new Error("AUTH_MAGIC_LINK_PROVIDER must be set to smtp in staging/production");
}
if (devMailboxEnabled) {
throw new Error("AUTH_DEV_MAILBOX_ENABLED must be false in staging/production");
}
}
function assertHTTPURL(value, envName) {
let parsed;
try {
parsed = new URL(value);
} catch (error) {
throw new Error(`${envName} must be a valid absolute URL`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${envName} must use http or https`);
}
}
function assertAbsoluteURL(value, envName) {
try {
// eslint-disable-next-line no-new
new URL(value);
} catch (error) {
throw new Error(`${envName} must be a valid absolute URL`);
}
}
function assertSecurePublicURL(value, envName) {
const parsed = new URL(value);
const hostname = parsed.hostname.toLowerCase();
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return;
}
if (parsed.protocol !== "https:") {
throw new Error(`${envName} must use https in staging/production`);
}
}
function isWeakSecret(secret) {
const normalized = secret.trim().toLowerCase();
if (
normalized === "" ||
normalized === "replace-me-with-a-long-random-secret" ||
normalized === "changeme" ||
normalized === "change-me" ||
normalized === "replace-me"
) {
return true;
}
return secret.trim().length < 16;
}
function resolveMagicLinkProvider(rawValue, isProductionLike) {
const normalized = String(rawValue || "")
.trim()
.toLowerCase();
if (!normalized) {
return isProductionLike ? "smtp" : "dev-mailbox";
}
if (normalized !== "dev-mailbox" && normalized !== "smtp") {
throw new Error("AUTH_MAGIC_LINK_PROVIDER must be one of: dev-mailbox, smtp");
}
return normalized;
}
function resolveDevMailboxEnabled(rawValue, mode) {
const defaultEnabled = mode === "development" || mode === "test";
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
return defaultEnabled;
}
return parseBoolean(rawValue, "AUTH_DEV_MAILBOX_ENABLED");
}
function parseBoolean(rawValue, envName) {
const normalized = String(rawValue).trim().toLowerCase();
switch (normalized) {
case "1":
case "true":
case "yes":
case "on":
return true;
case "0":
case "false":
case "no":
case "off":
return false;
default:
throw new Error(`${envName} must be a boolean value`);
}
}
function loadSMTPConfig() {
const host = String(process.env.AUTH_SMTP_HOST || "").trim();
const username = String(process.env.AUTH_SMTP_USER || "").trim();
const password = String(process.env.AUTH_SMTP_PASSWORD || "");
const from = String(process.env.AUTH_MAIL_FROM || "").trim();
if (!host) {
throw new Error("AUTH_SMTP_HOST is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
if (!username) {
throw new Error("AUTH_SMTP_USER is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
if (!password) {
throw new Error("AUTH_SMTP_PASSWORD is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
if (!from) {
throw new Error("AUTH_MAIL_FROM is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
}
const portRaw = String(process.env.AUTH_SMTP_PORT || "587").trim();
const port = Number.parseInt(portRaw, 10);
if (Number.isNaN(port) || port < 1 || port > 65535) {
throw new Error("AUTH_SMTP_PORT must be a valid TCP port");
}
const secure = process.env.AUTH_SMTP_SECURE
? parseBoolean(process.env.AUTH_SMTP_SECURE, "AUTH_SMTP_SECURE")
: port === 465;
const skipVerify = process.env.AUTH_SMTP_SKIP_VERIFY
? parseBoolean(process.env.AUTH_SMTP_SKIP_VERIFY, "AUTH_SMTP_SKIP_VERIFY")
: false;
const tlsRejectUnauthorized = process.env.AUTH_SMTP_TLS_REJECT_UNAUTHORIZED
? parseBoolean(process.env.AUTH_SMTP_TLS_REJECT_UNAUTHORIZED, "AUTH_SMTP_TLS_REJECT_UNAUTHORIZED")
: true;
return {
host,
port,
secure,
username,
password,
from,
skipVerify,
tlsRejectUnauthorized
};
}
function createMagicLinkTransport() {
if (magicLinkProvider === "dev-mailbox") {
return {
async send({ email, url }) {
await recordDevMail({
kind: "magic-link",
email,
subject: "Your Productier magic link",
url
});
console.log(`[Productier auth] magic link for ${email}: ${url}`);
},
async verify() {
return;
},
async close() {
return;
}
};
}
const transporter = nodemailer.createTransport({
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.secure,
auth: {
user: smtpConfig.username,
pass: smtpConfig.password
},
tls: {
rejectUnauthorized: smtpConfig.tlsRejectUnauthorized
}
});
return {
async send({ email, url }) {
const subject = "Your Productier magic link";
const text = [
"Sign in to Productier using this secure magic link:",
url,
"",
"If you did not request this link, you can ignore this email."
].join("\n");
const html = [
"<p>Sign in to Productier using this secure magic link:</p>",
`<p><a href="${url}">${url}</a></p>`,
"<p>If you did not request this link, you can ignore this email.</p>"
].join("");
await transporter.sendMail({
from: smtpConfig.from,
to: email,
subject,
text,
html
});
},
async verify() {
if (smtpConfig.skipVerify) {
return;
}
await transporter.verify();
},
async close() {
if (typeof transporter.close === "function") {
transporter.close();
}
}
};
}
@@ -0,0 +1,35 @@
import { promises as fs } from "node:fs";
import path from "node:path";
const MAILBOX_PATH = path.join(process.cwd(), ".productier-dev-mailbox.json");
async function readMailbox() {
try {
const raw = await fs.readFile(MAILBOX_PATH, "utf8");
const entries = JSON.parse(raw);
return Array.isArray(entries) ? entries : [];
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
export async function recordDevMail(entry) {
const mailbox = await readMailbox();
mailbox.unshift({
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...entry
});
await fs.writeFile(MAILBOX_PATH, JSON.stringify(mailbox.slice(0, 25), null, 2));
}
export async function listDevMail(email) {
const mailbox = await readMailbox();
if (!email) {
return mailbox;
}
return mailbox.filter(entry => entry.email.toLowerCase() === email.toLowerCase());
}
+152
View File
@@ -0,0 +1,152 @@
import http from "node:http";
import { URL } from "node:url";
import { toNodeHandler } from "better-auth/node";
import { auth, closeAuth, devMailboxEnabled, ensureAuthReady } from "./auth.mjs";
import { listDevMail } from "./dev-mailbox.mjs";
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const authPort = Number(process.env.AUTH_PORT || "3001");
const appMode = (process.env.APP_ENV || process.env.NODE_ENV || "development").trim().toLowerCase();
const enforceExplicitCORS = appMode === "staging" || appMode === "production";
const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOW_ORIGINS, frontendUrl, enforceExplicitCORS);
const authHandler = toNodeHandler(auth);
let isShuttingDown = false;
let shutdownTimer = null;
function setCorsHeaders(request, response) {
const requestOrigin = request.headers.origin;
if (requestOrigin && allowedOrigins.has(requestOrigin)) {
response.setHeader("Access-Control-Allow-Origin", requestOrigin);
} else if (!requestOrigin) {
response.setHeader("Access-Control-Allow-Origin", frontendUrl);
}
response.setHeader("Vary", "Origin");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
}
const server = http.createServer(async (request, response) => {
setCorsHeaders(request, response);
if (request.method === "OPTIONS") {
response.writeHead(204);
response.end();
return;
}
const url = new URL(request.url || "/", `http://${request.headers.host || `localhost:${authPort}`}`);
try {
if (url.pathname === "/api/dev-mailbox" && request.method === "GET" && devMailboxEnabled) {
const email = url.searchParams.get("email") || undefined;
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ data: await listDevMail(email) }));
return;
}
if (url.pathname.startsWith("/api/auth/")) {
authHandler(request, response);
return;
}
if (url.pathname === "/health" && request.method === "GET") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: true, service: "auth" }));
return;
}
response.writeHead(404, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Not found" }));
} catch (error) {
console.error("[Productier auth] request failed", error);
response.writeHead(500, { "Content-Type": "application/json" });
response.end(JSON.stringify({ error: "Internal server error" }));
}
});
async function bootstrap() {
try {
await ensureAuthReady();
} catch (error) {
console.error("[Productier auth] startup failed during auth initialization", error);
process.exit(1);
}
server.listen(authPort, () => {
console.log(`[Productier auth] listening on http://localhost:${authPort}`);
});
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.log(`[Productier auth] received ${signal}, shutting down`);
shutdownTimer = setTimeout(() => {
console.error("[Productier auth] forced shutdown after timeout");
process.exit(1);
}, 10000);
shutdownTimer.unref();
server.close(async closeError => {
if (closeError) {
console.error("[Productier auth] server close failed", closeError);
process.exitCode = 1;
}
try {
await closeAuth();
} catch (error) {
console.error("[Productier auth] auth shutdown failed", error);
process.exitCode = 1;
} finally {
if (shutdownTimer) {
clearTimeout(shutdownTimer);
}
process.exit();
}
});
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});
void bootstrap();
function parseAllowedOrigins(raw, fallbackOrigin, strictMode) {
const origins = new Set();
if (strictMode && (!raw || raw.trim().length === 0)) {
throw new Error("CORS_ALLOW_ORIGINS must be set in staging/production");
}
const source = raw && raw.trim().length > 0 ? raw : fallbackOrigin;
source
.split(",")
.map(value => value.trim())
.filter(Boolean)
.forEach(origin => {
try {
origins.add(new URL(origin).origin);
} catch (error) {
if (strictMode) {
throw new Error(`invalid origin in CORS_ALLOW_ORIGINS: ${origin}`);
}
console.warn(`[Productier auth] ignoring invalid CORS origin: ${origin}`);
}
});
if (origins.size === 0) {
if (strictMode) {
throw new Error("CORS_ALLOW_ORIGINS must include at least one valid origin");
}
origins.add(fallbackOrigin);
}
return origins;
}
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"context"
"os"
"os/signal"
"syscall"
"go.uber.org/zap"
"productier/apps/backend/internal/app"
)
func main() {
logger, err := zap.NewProduction()
if err != nil {
panic(err)
}
defer logger.Sync()
server, err := app.New(logger)
if err != nil {
logger.Fatal("create api app", zap.Error(err))
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := server.RunContext(ctx); err != nil {
logger.Fatal("run api server", zap.Error(err))
}
}
+60
View File
@@ -0,0 +1,60 @@
# Productier Backend Remote Deployment
# Usage: docker compose -f docker-compose.remote.yml --env-file remote.env up -d
#
# Prerequisites:
# 1. Copy remote.env to .env and configure all required values
# 2. Ensure PostgreSQL database is accessible
# 3. Ensure auth service is running and accessible at AUTH_SERVICE_URL
services:
api:
build:
context: .
dockerfile: Dockerfile
image: productier-api:latest
restart: unless-stopped
init: true
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
ports:
- "${API_PORT:-8080}:8080"
environment:
APP_ENV: ${APP_ENV:-production}
API_PORT: 8080
API_SHUTDOWN_TIMEOUT: ${API_SHUTDOWN_TIMEOUT:-15s}
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
AUTH_SERVICE_URL: ${AUTH_SERVICE_URL:?AUTH_SERVICE_URL is required}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:?CORS_ALLOW_ORIGINS is required}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET is required}
MAIL_ENCRYPTION_KEY: ${MAIL_ENCRYPTION_KEY:?MAIL_ENCRYPTION_KEY is required}
FILE_STORAGE_PROVIDER: ${FILE_STORAGE_PROVIDER:-local}
FILE_STORAGE_DIR: ${FILE_STORAGE_DIR:-/tmp/uploads}
DB_MIGRATIONS_DIR: /app/migrations
# S3 storage (if FILE_STORAGE_PROVIDER=s3)
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-productier}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
S3_USE_PATH_STYLE: ${S3_USE_PATH_STYLE:-false}
# Optional metrics auth
METRICS_AUTH_TOKEN: ${METRICS_AUTH_TOKEN:-}
volumes:
# Persist uploads if using local storage
- uploads-data:${FILE_STORAGE_DIR:-/tmp/uploads}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/v1/health"]
interval: 15s
timeout: 5s
retries: 20
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes:
uploads-data:
+67
View File
@@ -0,0 +1,67 @@
module productier/apps/backend
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.13
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.24.2
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-message v0.18.2
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/pressly/goose/v3 v3.27.0
go.uber.org/zap v1.27.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+194
View File
@@ -0,0 +1,194 @@
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"go.uber.org/zap"
"productier/apps/backend/internal/authsession"
"productier/apps/backend/internal/filestorage"
"productier/apps/backend/internal/httpapi"
"productier/apps/backend/internal/mailruntime"
"productier/apps/backend/internal/store"
)
type App struct {
server *httpapi.Server
port string
shutdownTimeout time.Duration
stopMailRuntime context.CancelFunc
}
func New(logger *zap.Logger) (*App, error) {
runtimeConfig, err := loadRuntimeConfig()
if err != nil {
return nil, err
}
var dataStore store.Store
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL != "" {
persistentStore, err := store.NewPostgresStore(databaseURL, runtimeConfig.mode)
if err != nil {
return nil, err
}
dataStore = persistentStore
} else {
if err := validateStoreRuntimeMode(runtimeConfig.mode, inMemoryStoreAllowed()); err != nil {
return nil, err
}
dataStore = store.NewSeededState(runtimeConfig.mode)
}
mailService, err := mailruntime.New(dataStore, logger, runtimeConfig.mailSecret)
if err != nil {
return nil, err
}
files, err := filestorage.NewFromEnv()
if err != nil {
return nil, err
}
probeCtx, cancelProbe := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelProbe()
if err := files.Probe(probeCtx); err != nil {
return nil, fmt.Errorf("file storage startup probe failed: %w", err)
}
mailRuntimeCtx, stopMailRuntime := context.WithCancel(context.Background())
mailService.Start(mailRuntimeCtx)
return &App{
server: httpapi.NewServer(
dataStore,
authsession.NewClient(runtimeConfig.authServiceURL),
mailService,
files,
runtimeConfig.mode,
runtimeConfig.corsAllowOrigins,
runtimeConfig.metricsAuthToken,
logger,
),
port: runtimeConfig.apiPort,
shutdownTimeout: runtimeConfig.shutdownTimeout,
stopMailRuntime: stopMailRuntime,
}, nil
}
func (a *App) Run() error {
return a.RunContext(context.Background())
}
func (a *App) RunContext(ctx context.Context) error {
httpServer := &http.Server{
Addr: fmt.Sprintf(":%s", a.port),
Handler: a.server.Engine(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
close(serverErr)
}()
select {
case <-ctx.Done():
if a.stopMailRuntime != nil {
a.stopMailRuntime()
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown api server: %w", err)
}
if err, ok := <-serverErr; ok && err != nil {
return fmt.Errorf("run api server: %w", err)
}
return nil
case err, ok := <-serverErr:
if a.stopMailRuntime != nil {
a.stopMailRuntime()
}
if !ok || err == nil {
return nil
}
return fmt.Errorf("run api server: %w", err)
}
}
func validateStoreRuntimeMode(mode string, allowInMemory bool) error {
if mode == "development" || allowInMemory {
return nil
}
return fmt.Errorf("DATABASE_URL is required when APP_ENV=%q (set ALLOW_INMEMORY_STORE=true only for temporary non-production testing)", mode)
}
func inMemoryStoreAllowed() bool {
raw := strings.TrimSpace(strings.ToLower(os.Getenv("ALLOW_INMEMORY_STORE")))
switch raw {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+72
View File
@@ -0,0 +1,72 @@
package app
import "testing"
func TestValidateStoreRuntimeMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mode string
allowInMemory bool
expectError bool
}{
{
name: "development allows in-memory store",
mode: "development",
allowInMemory: false,
expectError: false,
},
{
name: "production rejects in-memory store by default",
mode: "production",
allowInMemory: false,
expectError: true,
},
{
name: "non-development can be explicitly overridden",
mode: "staging",
allowInMemory: true,
expectError: false,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
err := validateStoreRuntimeMode(test.mode, test.allowInMemory)
if test.expectError && err == nil {
t.Fatalf("expected error for mode=%q allowInMemory=%v", test.mode, test.allowInMemory)
}
if !test.expectError && err != nil {
t.Fatalf("did not expect error for mode=%q allowInMemory=%v: %v", test.mode, test.allowInMemory, err)
}
})
}
}
func TestInMemoryStoreAllowed(t *testing.T) {
tests := []struct {
name string
value string
allowed bool
}{
{name: "empty", value: "", allowed: false},
{name: "true", value: "true", allowed: true},
{name: "uppercase true", value: "TRUE", allowed: true},
{name: "one", value: "1", allowed: true},
{name: "yes", value: "yes", allowed: true},
{name: "off", value: "off", allowed: false},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Setenv("ALLOW_INMEMORY_STORE", test.value)
if got := inMemoryStoreAllowed(); got != test.allowed {
t.Fatalf("inMemoryStoreAllowed() = %v, want %v for %q", got, test.allowed, test.value)
}
})
}
}
+256
View File
@@ -0,0 +1,256 @@
package app
import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const (
defaultAppMode = "development"
defaultAPIPort = "8080"
defaultShutdownTimeout = 10 * time.Second
)
var (
defaultLocalCORSOrigins = []string{
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
}
insecureSecretPlaceholders = map[string]struct{}{
"": {},
"replace-me-with-a-long-random-secret": {},
"replace-me-with-a-dedicated-mail-secret": {},
"productier-local-mail-key": {},
"changeme": {},
"change-me": {},
"replace-me": {},
}
)
type runtimeConfig struct {
mode string
apiPort string
authServiceURL string
shutdownTimeout time.Duration
corsAllowOrigins []string
mailSecret string
metricsAuthToken string
}
func loadRuntimeConfig() (runtimeConfig, error) {
mode, err := parseAppMode(os.Getenv("APP_ENV"))
if err != nil {
return runtimeConfig{}, err
}
apiPort, err := parsePort(os.Getenv("API_PORT"), defaultAPIPort, "API_PORT")
if err != nil {
return runtimeConfig{}, err
}
authServiceURL, err := parseAbsoluteHTTPURL(
valueOrDefault(strings.TrimSpace(os.Getenv("AUTH_SERVICE_URL")), "http://localhost:3001"),
"AUTH_SERVICE_URL",
)
if err != nil {
return runtimeConfig{}, err
}
shutdownTimeout, err := parseDuration(
os.Getenv("API_SHUTDOWN_TIMEOUT"),
defaultShutdownTimeout,
"API_SHUTDOWN_TIMEOUT",
)
if err != nil {
return runtimeConfig{}, err
}
corsAllowOrigins, err := parseCORSAllowOrigins(mode, os.Getenv("CORS_ALLOW_ORIGINS"))
if err != nil {
return runtimeConfig{}, err
}
mailSecret, err := resolveMailSecret(mode)
if err != nil {
return runtimeConfig{}, err
}
metricsAuthToken, err := resolveMetricsAuthToken(mode)
if err != nil {
return runtimeConfig{}, err
}
return runtimeConfig{
mode: mode,
apiPort: apiPort,
authServiceURL: authServiceURL,
shutdownTimeout: shutdownTimeout,
corsAllowOrigins: corsAllowOrigins,
mailSecret: mailSecret,
metricsAuthToken: metricsAuthToken,
}, nil
}
func parseAppMode(raw string) (string, error) {
mode := strings.TrimSpace(strings.ToLower(raw))
if mode == "" {
return defaultAppMode, nil
}
switch mode {
case "development", "test", "staging", "production":
return mode, nil
default:
return "", fmt.Errorf("unsupported APP_ENV %q (allowed: development, test, staging, production)", mode)
}
}
func parsePort(raw string, fallback string, envName string) (string, error) {
port := strings.TrimSpace(raw)
if port == "" {
port = fallback
}
numeric, err := strconv.Atoi(port)
if err != nil || numeric < 1 || numeric > 65535 {
return "", fmt.Errorf("%s must be a valid TCP port (1-65535)", envName)
}
return strconv.Itoa(numeric), nil
}
func parseDuration(raw string, fallback time.Duration, envName string) (time.Duration, error) {
value := strings.TrimSpace(raw)
if value == "" {
return fallback, nil
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a valid duration (example: 10s): %w", envName, err)
}
if duration <= 0 {
return 0, fmt.Errorf("%s must be greater than zero", envName)
}
return duration, nil
}
func parseAbsoluteHTTPURL(raw string, envName string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", fmt.Errorf("%s is required", envName)
}
parsed, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("%s must be a valid URL: %w", envName, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("%s must use http or https", envName)
}
if parsed.Host == "" {
return "", fmt.Errorf("%s must include a host", envName)
}
return strings.TrimRight(parsed.String(), "/"), nil
}
func parseCORSAllowOrigins(mode string, raw string) ([]string, error) {
value := strings.TrimSpace(raw)
if value == "" {
if mode == "staging" || mode == "production" {
return nil, errors.New("CORS_ALLOW_ORIGINS is required in staging/production (comma-separated origins)")
}
return append([]string(nil), defaultLocalCORSOrigins...), nil
}
parts := strings.Split(value, ",")
origins := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
origin := strings.TrimSpace(part)
if origin == "" {
continue
}
if origin == "*" {
return nil, errors.New("CORS_ALLOW_ORIGINS cannot include '*' when credentials are enabled")
}
validated, err := parseOrigin(origin, "CORS_ALLOW_ORIGINS")
if err != nil {
return nil, err
}
if _, exists := seen[validated]; exists {
continue
}
seen[validated] = struct{}{}
origins = append(origins, validated)
}
if len(origins) == 0 {
return nil, errors.New("CORS_ALLOW_ORIGINS must include at least one valid origin")
}
return origins, nil
}
func parseOrigin(raw string, envName string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("%s must contain valid origins: %w", envName, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("%s origins must use http or https", envName)
}
if parsed.Host == "" {
return "", fmt.Errorf("%s origins must include a host", envName)
}
if parsed.Path != "" && parsed.Path != "/" {
return "", fmt.Errorf("%s origins cannot include URL paths", envName)
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("%s origins cannot include query or fragment components", envName)
}
return parsed.Scheme + "://" + parsed.Host, nil
}
func resolveMailSecret(mode string) (string, error) {
mailSecret := strings.TrimSpace(os.Getenv("MAIL_ENCRYPTION_KEY"))
if mailSecret == "" {
mailSecret = strings.TrimSpace(os.Getenv("BETTER_AUTH_SECRET"))
}
if mode != "staging" && mode != "production" {
return mailSecret, nil
}
if isInsecureSecret(mailSecret) {
return "", errors.New("set a strong MAIL_ENCRYPTION_KEY (or BETTER_AUTH_SECRET fallback) for staging/production")
}
return mailSecret, nil
}
func resolveMetricsAuthToken(mode string) (string, error) {
token := strings.TrimSpace(os.Getenv("METRICS_AUTH_TOKEN"))
if token == "" {
return "", nil
}
if mode == "production" && isInsecureSecret(token) {
return "", errors.New("METRICS_AUTH_TOKEN must be a strong non-placeholder secret when set in production")
}
return token, nil
}
func isInsecureSecret(secret string) bool {
normalized := strings.TrimSpace(strings.ToLower(secret))
if _, exists := insecureSecretPlaceholders[normalized]; exists {
return true
}
return len(strings.TrimSpace(secret)) < 16
}
func valueOrDefault(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
+146
View File
@@ -0,0 +1,146 @@
package app
import (
"testing"
"time"
)
func TestParseAppMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
expectErr bool
}{
{name: "default", value: "", want: "development"},
{name: "production", value: "production", want: "production"},
{name: "normalized", value: " StAgInG ", want: "staging"},
{name: "invalid", value: "prod", expectErr: true},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got, err := parseAppMode(test.value)
if test.expectErr {
if err == nil {
t.Fatalf("expected error for %q", test.value)
}
return
}
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if got != test.want {
t.Fatalf("parseAppMode(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestParseDuration(t *testing.T) {
t.Parallel()
got, err := parseDuration("", 10*time.Second, "API_SHUTDOWN_TIMEOUT")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if got != 10*time.Second {
t.Fatalf("parseDuration default = %s, want 10s", got)
}
got, err = parseDuration("15s", 10*time.Second, "API_SHUTDOWN_TIMEOUT")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if got != 15*time.Second {
t.Fatalf("parseDuration explicit = %s, want 15s", got)
}
if _, err := parseDuration("0s", 10*time.Second, "API_SHUTDOWN_TIMEOUT"); err == nil {
t.Fatal("expected zero duration to fail")
}
if _, err := parseDuration("nope", 10*time.Second, "API_SHUTDOWN_TIMEOUT"); err == nil {
t.Fatal("expected invalid duration to fail")
}
}
func TestParseCORSAllowOrigins(t *testing.T) {
t.Parallel()
devOrigins, err := parseCORSAllowOrigins("development", "")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if len(devOrigins) == 0 {
t.Fatal("expected default development origins")
}
if _, err := parseCORSAllowOrigins("production", ""); err == nil {
t.Fatal("expected production with empty CORS_ALLOW_ORIGINS to fail")
}
origins, err := parseCORSAllowOrigins("production", "https://app.example.com, https://app.example.com ,https://admin.example.com")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if len(origins) != 2 {
t.Fatalf("expected 2 deduplicated origins, got %d", len(origins))
}
if _, err := parseCORSAllowOrigins("production", "*"); err == nil {
t.Fatal("expected wildcard origin to fail")
}
if _, err := parseCORSAllowOrigins("production", "https://app.example.com/path"); err == nil {
t.Fatal("expected origin with path to fail")
}
}
func TestIsInsecureSecret(t *testing.T) {
t.Parallel()
if !isInsecureSecret("replace-me-with-a-long-random-secret") {
t.Fatal("expected placeholder secret to be insecure")
}
if !isInsecureSecret("short") {
t.Fatal("expected short secret to be insecure")
}
if isInsecureSecret("this-is-a-strong-enough-secret-12345") {
t.Fatal("expected long random secret to pass")
}
}
func TestResolveMetricsAuthToken(t *testing.T) {
t.Run("empty token is allowed", func(t *testing.T) {
t.Setenv("METRICS_AUTH_TOKEN", "")
token, err := resolveMetricsAuthToken("production")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if token != "" {
t.Fatalf("token = %q, want empty", token)
}
})
t.Run("rejects weak token in production", func(t *testing.T) {
t.Setenv("METRICS_AUTH_TOKEN", "short")
if _, err := resolveMetricsAuthToken("production"); err == nil {
t.Fatal("expected weak production token to fail")
}
})
t.Run("allows token in production", func(t *testing.T) {
t.Setenv("METRICS_AUTH_TOKEN", "this-is-a-strong-enough-secret-98765")
token, err := resolveMetricsAuthToken("production")
if err != nil {
t.Fatalf("did not expect error: %v", err)
}
if token == "" {
t.Fatal("expected resolved token")
}
})
}
@@ -0,0 +1,66 @@
package authsession
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type sessionEnvelope struct {
User *User `json:"user"`
}
type Client struct {
baseURL string
httpClient *http.Client
}
func NewClient(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
}
}
func (c *Client) GetUser(ctx context.Context, cookieHeader string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/auth/get-session", nil)
if err != nil {
return nil, fmt.Errorf("create auth session request: %w", err)
}
if cookieHeader != "" {
req.Header.Set("Cookie", cookieHeader)
}
res, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request auth session: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("auth session status: %s", res.Status)
}
var payload *sessionEnvelope
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decode auth session: %w", err)
}
if payload == nil || payload.User == nil {
return nil, nil
}
return payload.User, nil
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
@@ -0,0 +1,174 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
import (
"database/sql"
"encoding/json"
"time"
)
type ActivityEntry struct {
ID string
WorkspaceSlug string
Title string
Detail string
CreatedAt time.Time
}
type BoardGroup struct {
ID string
WorkspaceSlug string
Name string
Color string
SortOrder int32
}
type CalendarEvent struct {
ID string
WorkspaceSlug string
Title string
Description string
StartsAt time.Time
EndsAt time.Time
Color string
LinkedTaskID sql.NullString
Attachments json.RawMessage
}
type FocusSession struct {
ID string
WorkspaceSlug string
TaskID sql.NullString
Mode string
StartedAt time.Time
CompletedAt sql.NullTime
PausedAt sql.NullTime
PausedTotalSeconds int32
DurationSeconds int32
}
type Invite struct {
ID string
WorkspaceSlug string
Email string
Role string
Token string
CreatedAt time.Time
Status string
}
type Label struct {
ID string
WorkspaceSlug string
Name string
Color string
}
type MailMessage struct {
ID string
WorkspaceSlug string
MailboxID string
RemoteUid int64
MessageID string
Folder string
FromAddress json.RawMessage
ToRecipients json.RawMessage
CcRecipients json.RawMessage
Subject string
Snippet string
TextBody string
HtmlBody string
ReceivedAt time.Time
IsRead bool
LinkedTaskID sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
}
type Mailbox struct {
ID string
WorkspaceSlug string
Label string
Email string
DisplayName string
ImapHost string
ImapPort int32
ImapUsername string
ImapPasswordCiphertext string
ImapUseTls bool
SmtpHost string
SmtpPort int32
SmtpUsername string
SmtpPasswordCiphertext string
SmtpUseTls bool
SyncStatus string
SyncError string
LastSyncedAt sql.NullTime
CreatedAt time.Time
UpdatedAt time.Time
}
type Member struct {
ID string
WorkspaceSlug string
Name string
Email string
Role string
Status string
}
type Note struct {
ID string
WorkspaceSlug string
Title string
Content string
UpdatedAt time.Time
}
type OutgoingMail struct {
ID string
WorkspaceSlug string
MailboxID string
ToRecipients json.RawMessage
CcRecipients json.RawMessage
BccRecipients json.RawMessage
Subject string
TextBody string
HtmlBody string
Status string
ScheduledFor sql.NullTime
SentAt sql.NullTime
ErrorMessage string
CreatedAt time.Time
UpdatedAt time.Time
}
type Task struct {
ID string
WorkspaceSlug string
BoardGroupID string
Title string
Description string
Status string
Color string
DueAt sql.NullTime
ScheduledStart sql.NullTime
ScheduledEnd sql.NullTime
AssigneeID sql.NullString
LabelIds json.RawMessage
Attachments json.RawMessage
Comments json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}
type Workspace struct {
ID string
Slug string
Name string
Role string
CreatedAt time.Time
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS workspaces (
id text PRIMARY KEY,
slug text NOT NULL UNIQUE,
name text NOT NULL,
role text NOT NULL,
created_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS members (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
email text NOT NULL,
role text NOT NULL,
status text NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS members_workspace_email_idx ON members (workspace_slug, email);
CREATE TABLE IF NOT EXISTS invites (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
email text NOT NULL,
role text NOT NULL,
token text NOT NULL UNIQUE,
created_at timestamptz NOT NULL,
status text NOT NULL
);
CREATE TABLE IF NOT EXISTS activity_entries (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
title text NOT NULL,
detail text NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_entries_workspace_created_idx ON activity_entries (workspace_slug, created_at DESC);
CREATE TABLE IF NOT EXISTS board_groups (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
color text NOT NULL,
sort_order integer NOT NULL
);
CREATE TABLE IF NOT EXISTS labels (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
color text NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
board_group_id text NOT NULL REFERENCES board_groups(id) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
status text NOT NULL,
color text NOT NULL,
due_at timestamptz,
scheduled_start timestamptz,
scheduled_end timestamptz,
assignee_id text,
label_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
attachments jsonb NOT NULL DEFAULT '[]'::jsonb,
comments jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS tasks_workspace_updated_idx ON tasks (workspace_slug, updated_at DESC);
CREATE TABLE IF NOT EXISTS calendar_events (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
title text NOT NULL,
description text NOT NULL DEFAULT '',
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
color text NOT NULL,
linked_task_id text,
attachments jsonb NOT NULL DEFAULT '[]'::jsonb
);
CREATE INDEX IF NOT EXISTS calendar_events_workspace_starts_idx ON calendar_events (workspace_slug, starts_at ASC);
CREATE TABLE IF NOT EXISTS notes (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
title text NOT NULL,
content text NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS focus_sessions (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
task_id text,
mode text NOT NULL,
started_at timestamptz NOT NULL,
completed_at timestamptz,
paused_at timestamptz,
paused_total_seconds integer NOT NULL DEFAULT 0,
duration_seconds integer NOT NULL
);
CREATE INDEX IF NOT EXISTS focus_sessions_workspace_started_idx ON focus_sessions (workspace_slug, started_at DESC);
-- +goose Down
DROP TABLE IF EXISTS focus_sessions;
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS calendar_events;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS labels;
DROP TABLE IF EXISTS board_groups;
DROP TABLE IF EXISTS activity_entries;
DROP TABLE IF EXISTS invites;
DROP INDEX IF EXISTS members_workspace_email_idx;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS workspaces;
@@ -0,0 +1,75 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS mailboxes (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
label text NOT NULL,
email text NOT NULL,
display_name text NOT NULL DEFAULT '',
imap_host text NOT NULL,
imap_port integer NOT NULL,
imap_username text NOT NULL,
imap_password_ciphertext text NOT NULL,
imap_use_tls boolean NOT NULL DEFAULT true,
smtp_host text NOT NULL,
smtp_port integer NOT NULL,
smtp_username text NOT NULL,
smtp_password_ciphertext text NOT NULL,
smtp_use_tls boolean NOT NULL DEFAULT true,
sync_status text NOT NULL DEFAULT 'idle',
sync_error text NOT NULL DEFAULT '',
last_synced_at timestamptz,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS mailboxes_workspace_updated_idx ON mailboxes (workspace_slug, updated_at DESC);
CREATE TABLE IF NOT EXISTS mail_messages (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
mailbox_id text NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
remote_uid bigint NOT NULL,
message_id text NOT NULL DEFAULT '',
folder text NOT NULL DEFAULT 'INBOX',
from_address jsonb NOT NULL DEFAULT '{}'::jsonb,
to_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
cc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
subject text NOT NULL DEFAULT '',
snippet text NOT NULL DEFAULT '',
text_body text NOT NULL DEFAULT '',
html_body text NOT NULL DEFAULT '',
received_at timestamptz NOT NULL,
is_read boolean NOT NULL DEFAULT false,
linked_task_id text,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS mail_messages_mailbox_folder_uid_idx ON mail_messages (mailbox_id, folder, remote_uid);
CREATE INDEX IF NOT EXISTS mail_messages_workspace_received_idx ON mail_messages (workspace_slug, received_at DESC);
CREATE TABLE IF NOT EXISTS outgoing_mails (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
mailbox_id text NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
to_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
cc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
bcc_recipients jsonb NOT NULL DEFAULT '[]'::jsonb,
subject text NOT NULL DEFAULT '',
text_body text NOT NULL DEFAULT '',
html_body text NOT NULL DEFAULT '',
status text NOT NULL,
scheduled_for timestamptz,
sent_at timestamptz,
error_message text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS outgoing_mails_workspace_created_idx ON outgoing_mails (workspace_slug, created_at DESC);
CREATE INDEX IF NOT EXISTS outgoing_mails_status_schedule_idx ON outgoing_mails (status, scheduled_for ASC);
-- +goose Down
DROP TABLE IF EXISTS outgoing_mails;
DROP TABLE IF EXISTS mail_messages;
DROP TABLE IF EXISTS mailboxes;
@@ -0,0 +1,79 @@
-- +goose Up
-- Contacts
CREATE TABLE IF NOT EXISTS contacts (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
first_name text NOT NULL DEFAULT '',
last_name text NOT NULL DEFAULT '',
email text NOT NULL DEFAULT '',
phone text NOT NULL DEFAULT '',
company_id text,
title text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
avatar_url text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS contacts_workspace_updated_idx ON contacts (workspace_slug, updated_at DESC);
CREATE INDEX IF NOT EXISTS contacts_workspace_email_idx ON contacts (workspace_slug, email);
-- Companies
CREATE TABLE IF NOT EXISTS companies (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
domain text NOT NULL DEFAULT '',
website text NOT NULL DEFAULT '',
industry text NOT NULL DEFAULT '',
size text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
logo_url text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS companies_workspace_updated_idx ON companies (workspace_slug, updated_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS companies_workspace_name_idx ON companies (workspace_slug, name);
-- Add company foreign key to contacts
ALTER TABLE contacts ADD CONSTRAINT contacts_company_fk FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL;
-- Contact-Task links
CREATE TABLE IF NOT EXISTS contact_tasks (
id text PRIMARY KEY,
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
task_id text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS contact_tasks_unique_idx ON contact_tasks (contact_id, task_id);
-- Contact-Event links
CREATE TABLE IF NOT EXISTS contact_events (
id text PRIMARY KEY,
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
event_id text NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS contact_events_unique_idx ON contact_events (contact_id, event_id);
-- Contact-Email links (track which contacts are involved in emails)
CREATE TABLE IF NOT EXISTS contact_emails (
id text PRIMARY KEY,
contact_id text NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
mail_message_id text NOT NULL REFERENCES mail_messages(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'recipient', -- sender, recipient, cc
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS contact_emails_unique_idx ON contact_emails (contact_id, mail_message_id);
-- +goose Down
DROP TABLE IF EXISTS contact_emails;
DROP TABLE IF EXISTS contact_events;
DROP TABLE IF EXISTS contact_tasks;
ALTER TABLE contacts DROP CONSTRAINT IF EXISTS contacts_company_fk;
DROP TABLE IF EXISTS contacts;
DROP TABLE IF EXISTS companies;
@@ -0,0 +1,71 @@
-- +goose Up
-- Recurring tasks
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_rule text NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS recurrence_end timestamptz;
-- Recurring events
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS recurrence_rule text NOT NULL DEFAULT '';
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS recurrence_end timestamptz;
-- Quick capture inbox
CREATE TABLE IF NOT EXISTS inbox_items (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
content text NOT NULL,
source text NOT NULL DEFAULT 'manual', -- manual, email, api
processed boolean NOT NULL DEFAULT false,
processed_at timestamptz,
processed_entity_type text, -- task, note, event
processed_entity_id text,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS inbox_items_workspace_created_idx ON inbox_items (workspace_slug, created_at DESC);
-- Time tracking
CREATE TABLE IF NOT EXISTS time_entries (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
task_id text REFERENCES tasks(id) ON DELETE CASCADE,
description text NOT NULL DEFAULT '',
started_at timestamptz NOT NULL,
ended_at timestamptz,
duration_seconds integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS time_entries_workspace_started_idx ON time_entries (workspace_slug, started_at DESC);
CREATE INDEX IF NOT EXISTS time_entries_task_idx ON time_entries (task_id);
-- Saved filters/views
CREATE TABLE IF NOT EXISTS saved_views (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
entity_type text NOT NULL, -- tasks, contacts, companies
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
sort_json jsonb NOT NULL DEFAULT '{}'::jsonb,
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS saved_views_workspace_type_idx ON saved_views (workspace_slug, entity_type);
-- Archive support
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE tasks DROP COLUMN IF EXISTS recurrence_rule;
ALTER TABLE tasks DROP COLUMN IF EXISTS recurrence_end;
ALTER TABLE calendar_events DROP COLUMN IF EXISTS recurrence_rule;
ALTER TABLE calendar_events DROP COLUMN IF EXISTS recurrence_end;
DROP TABLE IF EXISTS inbox_items;
DROP TABLE IF EXISTS time_entries;
DROP TABLE IF EXISTS saved_views;
ALTER TABLE tasks DROP COLUMN IF EXISTS archived;
ALTER TABLE calendar_events DROP COLUMN IF EXISTS archived;
ALTER TABLE notes DROP COLUMN IF EXISTS archived;
@@ -0,0 +1,70 @@
-- +goose Up
-- Integrations table for external service connections
CREATE TABLE IF NOT EXISTS integrations (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
provider text NOT NULL, -- google_calendar, slack, etc.
name text NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
credentials_ciphertext text NOT NULL,
status text NOT NULL DEFAULT 'active',
last_sync_at timestamptz,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS integrations_workspace_provider_idx ON integrations (workspace_slug, provider);
-- Webhooks for external notifications
CREATE TABLE IF NOT EXISTS webhooks (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
name text NOT NULL,
url text NOT NULL,
secret text NOT NULL,
events jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["task.created", "task.completed", etc.]
active boolean NOT NULL DEFAULT true,
last_triggered_at timestamptz,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS webhooks_workspace_idx ON webhooks (workspace_slug);
-- Notifications for users
CREATE TABLE IF NOT EXISTS notifications (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
user_email text NOT NULL,
type text NOT NULL, -- task_assigned, mention, comment, etc.
title text NOT NULL,
body text NOT NULL DEFAULT '',
entity_type text, -- task, event, note
entity_id text,
read boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications (user_email, created_at DESC);
CREATE INDEX IF NOT EXISTS notifications_unread_idx ON notifications (user_email, read) WHERE read = false;
-- Presence tracking for real-time collaboration
CREATE TABLE IF NOT EXISTS presence (
id text PRIMARY KEY,
workspace_slug text NOT NULL REFERENCES workspaces(slug) ON DELETE CASCADE,
user_email text NOT NULL,
user_name text NOT NULL,
entity_type text, -- board, task, note, etc.
entity_id text,
last_seen_at timestamptz NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS presence_workspace_entity_idx ON presence (workspace_slug, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS presence_user_idx ON presence (user_email);
-- +goose Down
DROP TABLE IF EXISTS presence;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS webhooks;
DROP TABLE IF EXISTS integrations;
+217
View File
@@ -0,0 +1,217 @@
-- name: CountWorkspaces :one
SELECT COUNT(*) FROM workspaces;
-- name: ListWorkspaces :many
SELECT id, slug, name, role, created_at
FROM workspaces
ORDER BY created_at ASC;
-- name: CreateWorkspace :exec
INSERT INTO workspaces (id, slug, name, role, created_at)
VALUES ($1, $2, $3, $4, $5);
-- name: ListMembers :many
SELECT id, workspace_slug, name, email, role, status
FROM members
WHERE workspace_slug = $1
ORDER BY name ASC;
-- name: CreateMember :exec
INSERT INTO members (id, workspace_slug, name, email, role, status)
VALUES ($1, $2, $3, $4, $5, $6);
-- name: GetMemberByWorkspaceAndEmail :one
SELECT id, workspace_slug, name, email, role, status
FROM members
WHERE workspace_slug = $1 AND email = $2;
-- name: ListInvites :many
SELECT id, workspace_slug, email, role, token, created_at, status
FROM invites
WHERE workspace_slug = $1
ORDER BY created_at DESC;
-- name: GetInviteByToken :one
SELECT id, workspace_slug, email, role, token, created_at, status
FROM invites
WHERE token = $1;
-- name: CreateInvite :one
INSERT INTO invites (id, workspace_slug, email, role, token, created_at, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, workspace_slug, email, role, token, created_at, status;
-- name: AcceptInvite :one
UPDATE invites
SET status = 'accepted'
WHERE token = $1
RETURNING id, workspace_slug, email, role, token, created_at, status;
-- name: ListActivities :many
SELECT id, workspace_slug, title, detail, created_at
FROM activity_entries
WHERE workspace_slug = $1
ORDER BY created_at DESC
LIMIT 40;
-- name: CreateActivity :exec
INSERT INTO activity_entries (id, workspace_slug, title, detail, created_at)
VALUES ($1, $2, $3, $4, $5);
-- name: TrimActivities :exec
DELETE FROM activity_entries
WHERE id IN (
SELECT id
FROM activity_entries
WHERE activity_entries.workspace_slug = $1
ORDER BY created_at DESC
OFFSET 40
);
-- name: ListBoardGroups :many
SELECT id, workspace_slug, name, color, sort_order
FROM board_groups
WHERE workspace_slug = $1
ORDER BY sort_order ASC, name ASC;
-- name: GetBoardGroupByID :one
SELECT id, workspace_slug, name, color, sort_order
FROM board_groups
WHERE id = $1;
-- name: CreateBoardGroup :one
INSERT INTO board_groups (id, workspace_slug, name, color, sort_order)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_slug, name, color, sort_order;
-- name: UpdateBoardGroup :one
UPDATE board_groups
SET name = $2,
color = $3,
sort_order = $4
WHERE id = $1
RETURNING id, workspace_slug, name, color, sort_order;
-- name: CountBoardGroupsByWorkspace :one
SELECT COUNT(*) FROM board_groups WHERE workspace_slug = $1;
-- name: ListLabels :many
SELECT id, workspace_slug, name, color
FROM labels
WHERE workspace_slug = $1
ORDER BY name ASC;
-- name: CreateLabel :one
INSERT INTO labels (id, workspace_slug, name, color)
VALUES ($1, $2, $3, $4)
RETURNING id, workspace_slug, name, color;
-- name: ListTasks :many
SELECT id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at
FROM tasks
WHERE workspace_slug = $1
ORDER BY updated_at DESC;
-- name: GetTaskByID :one
SELECT id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at
FROM tasks
WHERE id = $1;
-- name: CreateTask :one
INSERT INTO tasks (id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at;
-- name: UpdateTask :one
UPDATE tasks
SET title = $2,
description = $3,
status = $4,
board_group_id = $5,
color = $6,
due_at = $7,
scheduled_start = $8,
scheduled_end = $9,
assignee_id = $10,
label_ids = $11,
attachments = $12,
comments = $13,
updated_at = $14
WHERE id = $1
RETURNING id, workspace_slug, board_group_id, title, description, status, color, due_at, scheduled_start, scheduled_end, assignee_id, label_ids, attachments, comments, created_at, updated_at;
-- name: ListCalendarEvents :many
SELECT id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments
FROM calendar_events
WHERE workspace_slug = $1
ORDER BY starts_at ASC;
-- name: GetCalendarEventByID :one
SELECT id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments
FROM calendar_events
WHERE id = $1;
-- name: CreateCalendarEvent :one
INSERT INTO calendar_events (id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments;
-- name: UpdateCalendarEvent :one
UPDATE calendar_events
SET title = $2,
description = $3,
starts_at = $4,
ends_at = $5,
color = $6,
linked_task_id = $7,
attachments = $8
WHERE id = $1
RETURNING id, workspace_slug, title, description, starts_at, ends_at, color, linked_task_id, attachments;
-- name: ListNotes :many
SELECT id, workspace_slug, title, content, updated_at
FROM notes
WHERE workspace_slug = $1
ORDER BY updated_at DESC;
-- name: GetNoteByID :one
SELECT id, workspace_slug, title, content, updated_at
FROM notes
WHERE id = $1;
-- name: CreateNote :one
INSERT INTO notes (id, workspace_slug, title, content, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_slug, title, content, updated_at;
-- name: UpdateNote :one
UPDATE notes
SET title = $2,
content = $3,
updated_at = $4
WHERE id = $1
RETURNING id, workspace_slug, title, content, updated_at;
-- name: ListFocusSessions :many
SELECT id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds
FROM focus_sessions
WHERE workspace_slug = $1
ORDER BY started_at DESC;
-- name: GetFocusSessionByID :one
SELECT id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds
FROM focus_sessions
WHERE id = $1;
-- name: CreateFocusSession :one
INSERT INTO focus_sessions (id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds;
-- name: UpdateFocusSession :one
UPDATE focus_sessions
SET completed_at = $2,
paused_at = $3,
paused_total_seconds = $4
WHERE id = $1
RETURNING id, workspace_slug, task_id, mode, started_at, completed_at, paused_at, paused_total_seconds, duration_seconds;
@@ -0,0 +1,86 @@
package filestorage
import (
"context"
"errors"
"io"
"os"
"path/filepath"
)
type LocalStorage struct {
root string
}
func NewLocal(root string) *LocalStorage {
return &LocalStorage{root: root}
}
func (l *LocalStorage) Provider() string {
return "local"
}
func (l *LocalStorage) Probe(_ context.Context) error {
if err := os.MkdirAll(l.root, 0o755); err != nil {
return err
}
probePath := filepath.Join(l.root, ".healthcheck")
file, err := os.OpenFile(probePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
_ = os.Remove(probePath)
return nil
}
func (l *LocalStorage) Put(_ context.Context, key string, reader io.Reader, _ string, _ int64) error {
path := filepath.Join(l.root, filepath.Clean(key))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.Create(path)
if err != nil {
return err
}
if _, err := io.Copy(file, reader); err != nil {
_ = file.Close()
_ = os.Remove(path)
return err
}
return file.Close()
}
func (l *LocalStorage) Get(_ context.Context, key string) (Object, error) {
path := filepath.Join(l.root, filepath.Clean(key))
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Object{}, ErrNotFound
}
return Object{}, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return Object{}, err
}
if info.IsDir() {
_ = file.Close()
return Object{}, ErrNotFound
}
return Object{
Body: file,
Size: info.Size(),
}, nil
}
func (l *LocalStorage) Delete(_ context.Context, key string) error {
path := filepath.Join(l.root, filepath.Clean(key))
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package filestorage
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
type S3Storage struct {
client *s3.Client
bucket string
}
func NewS3FromEnv() (*S3Storage, error) {
bucket := strings.TrimSpace(os.Getenv("S3_BUCKET"))
accessKey := strings.TrimSpace(os.Getenv("S3_ACCESS_KEY"))
secretKey := strings.TrimSpace(os.Getenv("S3_SECRET_KEY"))
if bucket == "" || accessKey == "" || secretKey == "" {
return nil, errors.New("S3_BUCKET, S3_ACCESS_KEY, and S3_SECRET_KEY are required for FILE_STORAGE_PROVIDER=s3")
}
region := strings.TrimSpace(os.Getenv("S3_REGION"))
if region == "" {
region = "us-east-1"
}
endpoint := strings.TrimSpace(os.Getenv("S3_ENDPOINT"))
usePathStyle, _ := strconv.ParseBool(strings.TrimSpace(os.Getenv("S3_USE_PATH_STYLE")))
cfg := aws.Config{
Region: region,
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
}
client := s3.NewFromConfig(cfg, func(options *s3.Options) {
options.UsePathStyle = usePathStyle
if endpoint != "" {
options.BaseEndpoint = aws.String(endpoint)
}
})
return &S3Storage{client: client, bucket: bucket}, nil
}
func (s *S3Storage) Provider() string {
return "s3"
}
func (s *S3Storage) Probe(ctx context.Context) error {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
return err
}
func (s *S3Storage) Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: reader,
ContentType: aws.String(contentType),
ContentLength: aws.Int64(size),
})
return err
}
func (s *S3Storage) Get(ctx context.Context, key string) (Object, error) {
response, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFoundError(err) {
return Object{}, ErrNotFound
}
return Object{}, err
}
contentType := ""
if response.ContentType != nil {
contentType = *response.ContentType
}
size := int64(0)
if response.ContentLength != nil {
size = *response.ContentLength
}
return Object{
Body: response.Body,
ContentType: contentType,
Size: size,
}, nil
}
func (s *S3Storage) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFoundError(err) {
return nil
}
return fmt.Errorf("delete s3 object: %w", err)
}
return nil
}
func isS3NotFoundError(err error) bool {
var noSuchKey *types.NoSuchKey
if errors.As(err, &noSuchKey) {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "NoSuchKey", "NotFound", "NoSuchBucket":
return true
}
}
return false
}
@@ -0,0 +1,40 @@
package filestorage
import (
"context"
"errors"
"io"
"os"
"strings"
)
var ErrNotFound = errors.New("file storage object not found")
type Object struct {
Body io.ReadCloser
ContentType string
Size int64
}
type Storage interface {
Provider() string
Probe(ctx context.Context) error
Put(ctx context.Context, key string, reader io.Reader, contentType string, size int64) error
Get(ctx context.Context, key string) (Object, error)
Delete(ctx context.Context, key string) error
}
func NewFromEnv() (Storage, error) {
provider := strings.ToLower(strings.TrimSpace(os.Getenv("FILE_STORAGE_PROVIDER")))
if provider == "" || provider == "local" {
root := strings.TrimSpace(os.Getenv("FILE_STORAGE_DIR"))
if root == "" {
root = "./data/uploads"
}
return NewLocal(root), nil
}
if provider == "s3" {
return NewS3FromEnv()
}
return nil, errors.New("unsupported file storage provider")
}
@@ -0,0 +1,107 @@
package httpapi
import (
"fmt"
"strconv"
"strings"
"productier/apps/backend/internal/store"
)
const (
defaultActivityLimit = 8
maxActivityLimit = 40
)
var activityTypes = map[string]struct{}{
"task": {},
"board": {},
"calendar": {},
"note": {},
"focus": {},
"mail": {},
"invite": {},
"system": {},
}
type activityListParams struct {
Limit int
Type string
Query string
}
func parseActivityListParams(limitRaw string, typeRaw string, queryRaw string) (activityListParams, error) {
params := activityListParams{
Limit: defaultActivityLimit,
Type: strings.TrimSpace(strings.ToLower(typeRaw)),
Query: strings.TrimSpace(strings.ToLower(queryRaw)),
}
if strings.TrimSpace(limitRaw) != "" {
parsed, err := strconv.Atoi(limitRaw)
if err != nil {
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
}
if parsed < 1 || parsed > maxActivityLimit {
return activityListParams{}, fmt.Errorf("invalid limit: expected integer between 1 and %d", maxActivityLimit)
}
params.Limit = parsed
}
if params.Type != "" {
if _, ok := activityTypes[params.Type]; !ok {
return activityListParams{}, fmt.Errorf("invalid activity type")
}
}
return params, nil
}
func filterActivityEntries(entries []store.ActivityEntry, params activityListParams) []store.ActivityEntry {
if len(entries) == 0 || params.Limit == 0 {
return []store.ActivityEntry{}
}
filtered := make([]store.ActivityEntry, 0, params.Limit)
for _, entry := range entries {
if params.Type != "" && classifyActivityEntry(entry) != params.Type {
continue
}
if params.Query != "" {
title := strings.ToLower(entry.Title)
detail := strings.ToLower(entry.Detail)
if !strings.Contains(title, params.Query) && !strings.Contains(detail, params.Query) {
continue
}
}
filtered = append(filtered, entry)
if len(filtered) == params.Limit {
break
}
}
return filtered
}
func classifyActivityEntry(entry store.ActivityEntry) string {
text := strings.ToLower(strings.TrimSpace(entry.Title + " " + entry.Detail))
switch {
case strings.Contains(text, "invite"):
return "invite"
case strings.Contains(text, "board"):
return "board"
case strings.Contains(text, "mail"), strings.Contains(text, "inbox"), strings.Contains(text, "smtp"), strings.Contains(text, "imap"):
return "mail"
case strings.Contains(text, "calendar"), strings.Contains(text, "event"):
return "calendar"
case strings.Contains(text, "note"):
return "note"
case strings.Contains(text, "focus"), strings.Contains(text, "pomodoro"):
return "focus"
case strings.Contains(text, "task"):
return "task"
default:
return "system"
}
}
@@ -0,0 +1,96 @@
package httpapi
import (
"testing"
"time"
"productier/apps/backend/internal/store"
)
func TestParseActivityListParams(t *testing.T) {
t.Parallel()
params, err := parseActivityListParams("", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if params.Limit != defaultActivityLimit {
t.Fatalf("default limit = %d, want %d", params.Limit, defaultActivityLimit)
}
params, err = parseActivityListParams("12", "task", "foo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if params.Limit != 12 || params.Type != "task" || params.Query != "foo" {
t.Fatalf("unexpected parsed params: %+v", params)
}
if _, err := parseActivityListParams("0", "", ""); err == nil {
t.Fatal("expected error for out-of-range limit")
}
if _, err := parseActivityListParams("abc", "", ""); err == nil {
t.Fatal("expected error for non-numeric limit")
}
if _, err := parseActivityListParams("5", "unknown", ""); err == nil {
t.Fatal("expected error for invalid activity type")
}
}
func TestFilterActivityEntries(t *testing.T) {
t.Parallel()
entries := []store.ActivityEntry{
{ID: "1", Title: "Task created", Detail: "Write docs", CreatedAt: time.Now().Add(-1 * time.Hour)},
{ID: "2", Title: "Invite accepted", Detail: "alex@example.com joined", CreatedAt: time.Now().Add(-2 * time.Hour)},
{ID: "3", Title: "Mail synced", Detail: "Inbox updated", CreatedAt: time.Now().Add(-3 * time.Hour)},
}
filtered := filterActivityEntries(entries, activityListParams{
Limit: 5,
Type: "invite",
})
if len(filtered) != 1 || filtered[0].ID != "2" {
t.Fatalf("expected invite entry only, got %+v", filtered)
}
filtered = filterActivityEntries(entries, activityListParams{
Limit: 5,
Query: "docs",
})
if len(filtered) != 1 || filtered[0].ID != "1" {
t.Fatalf("expected docs match only, got %+v", filtered)
}
filtered = filterActivityEntries(entries, activityListParams{
Limit: 2,
})
if len(filtered) != 2 {
t.Fatalf("expected two entries due to limit, got %d", len(filtered))
}
}
func TestClassifyActivityEntry(t *testing.T) {
t.Parallel()
cases := []struct {
entry store.ActivityEntry
want string
}{
{entry: store.ActivityEntry{Title: "Task updated", Detail: "Done"}, want: "task"},
{entry: store.ActivityEntry{Title: "Board group added", Detail: "Inbox"}, want: "board"},
{entry: store.ActivityEntry{Title: "Event moved", Detail: "Calendar item"}, want: "calendar"},
{entry: store.ActivityEntry{Title: "Note saved", Detail: "Draft"}, want: "note"},
{entry: store.ActivityEntry{Title: "Focus started", Detail: "Pomodoro"}, want: "focus"},
{entry: store.ActivityEntry{Title: "Mail sent", Detail: "SMTP ok"}, want: "mail"},
{entry: store.ActivityEntry{Title: "Invite revoked", Detail: "guest"}, want: "invite"},
{entry: store.ActivityEntry{Title: "Workspace synced", Detail: "ok"}, want: "system"},
}
for _, test := range cases {
got := classifyActivityEntry(test.entry)
if got != test.want {
t.Fatalf("classifyActivityEntry(%q) = %q, want %q", test.entry.Title, got, test.want)
}
}
}
+214
View File
@@ -0,0 +1,214 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/store"
)
func (s *Server) registerCRMRoutes(group *gin.RouterGroup) {
// Contacts
group.GET("/contacts", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListContacts(workspaceSlug)})
})
group.GET("/contacts/:contactId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": contact})
})
group.POST("/contacts", func(c *gin.Context) {
var input store.CreateContactInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
contact := s.store.CreateContact(input)
c.JSON(http.StatusCreated, gin.H{"data": contact})
})
group.PATCH("/contacts/:contactId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
var input store.UpdateContactInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateContact(c.Param("contactId"), input)
if err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.DELETE("/contacts/:contactId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.DeleteContact(c.Param("contactId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Companies
group.GET("/companies", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListCompanies(workspaceSlug)})
})
group.GET("/companies/:companyId", func(c *gin.Context) {
company, err := s.store.GetCompanyByID(c.Param("companyId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": company})
})
group.POST("/companies", func(c *gin.Context) {
var input store.CreateCompanyInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
company := s.store.CreateCompany(input)
c.JSON(http.StatusCreated, gin.H{"data": company})
})
group.PATCH("/companies/:companyId", func(c *gin.Context) {
company, err := s.store.GetCompanyByID(c.Param("companyId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
return
}
var input store.UpdateCompanyInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateCompany(c.Param("companyId"), input)
if err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.DELETE("/companies/:companyId", func(c *gin.Context) {
company, err := s.store.GetCompanyByID(c.Param("companyId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, company.WorkspaceSlug); !ok {
return
}
if err := s.store.DeleteCompany(c.Param("companyId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Contact-Task linking
group.POST("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.LinkContactToTask(c.Param("contactId"), c.Param("taskId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.DELETE("/contacts/:contactId/tasks/:taskId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.UnlinkContactFromTask(c.Param("contactId"), c.Param("taskId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Contact-Event linking
group.POST("/contacts/:contactId/events/:eventId", func(c *gin.Context) {
contact, err := s.store.GetContactByID(c.Param("contactId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, contact.WorkspaceSlug); !ok {
return
}
if err := s.store.LinkContactToEvent(c.Param("contactId"), c.Param("eventId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
+62
View File
@@ -0,0 +1,62 @@
package httpapi
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const requestIDContextKey = "requestID"
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := strings.TrimSpace(c.GetHeader("X-Request-Id"))
if requestID == "" {
requestID = uuid.NewString()
}
c.Set(requestIDContextKey, requestID)
c.Header("X-Request-Id", requestID)
c.Next()
}
}
func requestIDFromContext(c *gin.Context) string {
if value, exists := c.Get(requestIDContextKey); exists {
if requestID, ok := value.(string); ok {
return requestID
}
}
return ""
}
func (s *Server) writeStatusError(c *gin.Context, status int, message string) {
code := "internal_error"
switch status {
case http.StatusBadRequest:
code = "bad_request"
case http.StatusUnauthorized:
code = "unauthorized"
case http.StatusForbidden:
code = "forbidden"
case http.StatusNotFound:
code = "not_found"
case http.StatusConflict:
code = "conflict"
case http.StatusBadGateway:
code = "upstream_error"
case http.StatusServiceUnavailable:
code = "service_unavailable"
}
if strings.TrimSpace(message) == "" {
message = http.StatusText(status)
}
c.JSON(status, gin.H{
"error": gin.H{
"code": code,
"message": message,
"requestId": requestIDFromContext(c),
},
})
}
@@ -0,0 +1,175 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/store"
)
func (s *Server) registerIntegrationRoutes(group *gin.RouterGroup) {
// Integrations
group.GET("/integrations", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListIntegrations(workspaceSlug)})
})
group.POST("/integrations", func(c *gin.Context) {
var input store.CreateIntegrationInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
integration := s.store.CreateIntegration(input)
c.JSON(http.StatusCreated, gin.H{"data": integration})
})
group.DELETE("/integrations/:integrationId", func(c *gin.Context) {
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
return
}
if err := s.store.DeleteIntegration(integration.ID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Webhooks
group.GET("/webhooks", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListWebhooks(workspaceSlug)})
})
group.POST("/webhooks", func(c *gin.Context) {
var input store.CreateWebhookInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
webhook := s.store.CreateWebhook(input)
c.JSON(http.StatusCreated, gin.H{"data": webhook})
})
group.DELETE("/webhooks/:webhookId", func(c *gin.Context) {
if err := s.store.DeleteWebhook(c.Param("webhookId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Notifications
group.GET("/notifications", func(c *gin.Context) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
limit := 50
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotifications(user.Email, limit)})
})
group.POST("/notifications/:notificationId/read", func(c *gin.Context) {
if err := s.store.MarkNotificationRead(c.Param("notificationId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.POST("/notifications/read-all", func(c *gin.Context) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
if err := s.store.MarkAllNotificationsRead(user.Email); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.GET("/notifications/unread-count", func(c *gin.Context) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
c.JSON(http.StatusOK, gin.H{"count": s.store.UnreadNotificationCount(user.Email)})
})
// Presence
group.POST("/presence", func(c *gin.Context) {
var input store.UpdatePresenceInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
presence := s.store.UpdatePresence(input)
c.JSON(http.StatusOK, gin.H{"data": presence})
})
// Create notification (internal use)
group.POST("/notifications", func(c *gin.Context) {
var input store.CreateNotificationInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
notification := s.store.CreateNotification(input)
c.JSON(http.StatusCreated, gin.H{"data": notification})
})
group.GET("/presence", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
entityType := c.Query("entityType")
entityID := c.Query("entityId")
c.JSON(http.StatusOK, gin.H{"data": s.store.ListPresence(workspaceSlug, entityType, entityID)})
})
group.DELETE("/presence", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return
}
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
if err := s.store.ClearPresence(workspaceSlug, user.Email); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
+57
View File
@@ -0,0 +1,57 @@
package httpapi
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func requestLogMiddleware(logger *zap.Logger) gin.HandlerFunc {
baseLogger := logger
if baseLogger == nil {
baseLogger = zap.NewNop()
}
return func(c *gin.Context) {
startedAt := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
status := c.Writer.Status()
latency := time.Since(startedAt)
requestID := requestIDFromContext(c)
if path == "/v1/health" && status < 400 {
return
}
fields := []zap.Field{
zap.String("requestId", requestID),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.Int("status", status),
zap.Duration("latency", latency),
zap.String("clientIP", c.ClientIP()),
zap.String("userAgent", c.Request.UserAgent()),
zap.Int("responseBytes", c.Writer.Size()),
}
if query != "" {
fields = append(fields, zap.String("query", query))
}
if len(c.Errors) > 0 {
fields = append(fields, zap.String("errors", c.Errors.String()))
}
switch {
case status >= 500:
baseLogger.Error("http request completed with server error", fields...)
case status >= 400:
baseLogger.Warn("http request completed with client error", fields...)
default:
baseLogger.Info("http request completed", fields...)
}
}
}
@@ -0,0 +1,229 @@
package httpapi
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/mailruntime"
"productier/apps/backend/internal/store"
)
type connectMailboxRequest struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Label string `json:"label"`
Email string `json:"email" binding:"required,email"`
DisplayName string `json:"displayName"`
IMAPHost string `json:"imapHost" binding:"required"`
IMAPPort int `json:"imapPort"`
IMAPUsername string `json:"imapUsername"`
IMAPPassword string `json:"imapPassword" binding:"required"`
IMAPUseTLS bool `json:"imapUseTls"`
SMTPHost string `json:"smtpHost" binding:"required"`
SMTPPort int `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPUseTLS bool `json:"smtpUseTls"`
}
type createOutgoingMailRequest struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
MailboxID string `json:"mailboxId" binding:"required"`
To []store.MailAddress `json:"to" binding:"required"`
Cc []store.MailAddress `json:"cc"`
Bcc []store.MailAddress `json:"bcc"`
Subject string `json:"subject"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody"`
ScheduledFor *time.Time `json:"scheduledFor"`
}
type createTaskFromMailRequest struct {
BoardGroupID string `json:"boardGroupId" binding:"required"`
Title string `json:"title"`
DueAt *time.Time `json:"dueAt"`
Color string `json:"color"`
}
func (s *Server) registerMailRoutes(group *gin.RouterGroup) {
group.GET("/mailboxes", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailboxes(workspaceSlug)})
})
group.POST("/mailboxes", func(c *gin.Context) {
var input connectMailboxRequest
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
mailbox, err := s.mail.ConnectMailbox(c.Request.Context(), mailruntime.ConnectMailboxInput{
WorkspaceSlug: input.WorkspaceSlug,
Label: input.Label,
Email: input.Email,
DisplayName: input.DisplayName,
IMAPHost: input.IMAPHost,
IMAPPort: input.IMAPPort,
IMAPUsername: input.IMAPUsername,
IMAPPassword: input.IMAPPassword,
IMAPUseTLS: input.IMAPUseTLS,
SMTPHost: input.SMTPHost,
SMTPPort: input.SMTPPort,
SMTPUsername: input.SMTPUsername,
SMTPPassword: input.SMTPPassword,
SMTPUseTLS: input.SMTPUseTLS,
})
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{"data": mailbox})
})
group.POST("/mailboxes/:mailboxId/sync", func(c *gin.Context) {
mailbox, err := s.store.GetMailboxByID(c.Param("mailboxId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, mailbox.WorkspaceSlug); !ok {
return
}
if err := s.mail.SyncMailbox(c.Request.Context(), mailbox.ID); err != nil {
s.writeStatusError(c, http.StatusBadGateway, err.Error())
return
}
updated, err := s.store.GetMailboxByID(mailbox.ID)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.GET("/mail/messages", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailMessages(workspaceSlug, c.Query("mailboxId"))})
})
group.GET("/mail/outgoing", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListOutgoingMails(workspaceSlug, c.Query("mailboxId"))})
})
group.POST("/mail/outgoing", func(c *gin.Context) {
var input createOutgoingMailRequest
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
mailbox, err := s.store.GetMailboxByID(input.MailboxID)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if mailbox.WorkspaceSlug != input.WorkspaceSlug {
s.writeStatusError(c, http.StatusForbidden, "mailbox does not belong to workspace")
return
}
item, err := s.mail.QueueOutgoingMail(c.Request.Context(), mailruntime.QueueOutgoingMailInput{
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: input.MailboxID,
To: input.To,
Cc: input.Cc,
Bcc: input.Bcc,
Subject: input.Subject,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
ScheduledFor: input.ScheduledFor,
})
if err != nil {
s.writeStatusError(c, http.StatusBadGateway, err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
})
group.POST("/mail/messages/:messageId/create-task", func(c *gin.Context) {
message, err := s.store.GetMailMessageByID(c.Param("messageId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, message.WorkspaceSlug); !ok {
return
}
if message.LinkedTaskID != nil {
s.writeStatusError(c, http.StatusConflict, "message already linked to a task")
return
}
var input createTaskFromMailRequest
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
description := mailTaskDescription(message)
task := s.store.CreateTask(store.CreateTaskInput{
WorkspaceSlug: message.WorkspaceSlug,
BoardGroupID: input.BoardGroupID,
Title: firstNonBlank(strings.TrimSpace(input.Title), strings.TrimSpace(message.Subject), "Follow up on email"),
Description: description,
DueAt: input.DueAt,
Color: withFallback(input.Color, "blue"),
})
if _, err := s.store.LinkMailMessageTask(message.ID, task.ID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{"data": task})
})
}
func mailTaskDescription(message store.MailMessage) string {
var builder strings.Builder
if message.From.Email != "" {
builder.WriteString(fmt.Sprintf("From: %s <%s>\n\n", firstNonBlank(message.From.Name, "Sender"), message.From.Email))
}
body := firstNonBlank(strings.TrimSpace(message.TextBody), strings.TrimSpace(message.Snippet))
builder.WriteString(body)
return strings.TrimSpace(builder.String())
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func withFallback(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
+263
View File
@@ -0,0 +1,263 @@
package httpapi
import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type routeMetricSnapshot struct {
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
Count uint64 `json:"count"`
AvgLatencyMs float64 `json:"avgLatencyMs"`
MaxLatencyMs float64 `json:"maxLatencyMs"`
LastSeenAt string `json:"lastSeenAt"`
}
type metricsSnapshot struct {
GeneratedAt string `json:"generatedAt"`
UptimeSeconds int64 `json:"uptimeSeconds"`
RequestsTotal uint64 `json:"requestsTotal"`
StatusClassTotals map[string]uint64 `json:"statusClassTotals"`
Routes []routeMetricSnapshot `json:"routes"`
}
type routeMetricBucket struct {
Method string
Path string
Status int
Count uint64
TotalLatencyNanos float64
MaxLatencyNanos float64
LastSeenAt time.Time
}
type requestMetrics struct {
startedAt time.Time
requestsTotal uint64
status2xxTotal uint64
status3xxTotal uint64
status4xxTotal uint64
status5xxTotal uint64
statusOther uint64
mu sync.RWMutex
buckets map[string]*routeMetricBucket
}
func newRequestMetrics() *requestMetrics {
return &requestMetrics{
startedAt: time.Now().UTC(),
buckets: make(map[string]*routeMetricBucket),
}
}
func (m *requestMetrics) observe(method, path string, status int, latency time.Duration) {
if m == nil {
return
}
if path == "" {
path = "<unmatched>"
}
now := time.Now().UTC()
latencyNanos := float64(latency.Nanoseconds())
key := method + " " + path + " " + itoa(status)
m.mu.Lock()
defer m.mu.Unlock()
m.requestsTotal++
switch {
case status >= 200 && status < 300:
m.status2xxTotal++
case status >= 300 && status < 400:
m.status3xxTotal++
case status >= 400 && status < 500:
m.status4xxTotal++
case status >= 500 && status < 600:
m.status5xxTotal++
default:
m.statusOther++
}
bucket, exists := m.buckets[key]
if !exists {
bucket = &routeMetricBucket{
Method: method,
Path: path,
Status: status,
Count: 1,
TotalLatencyNanos: latencyNanos,
MaxLatencyNanos: latencyNanos,
LastSeenAt: now,
}
m.buckets[key] = bucket
return
}
bucket.Count++
bucket.TotalLatencyNanos += latencyNanos
if latencyNanos > bucket.MaxLatencyNanos {
bucket.MaxLatencyNanos = latencyNanos
}
bucket.LastSeenAt = now
}
func (m *requestMetrics) snapshot() metricsSnapshot {
if m == nil {
return metricsSnapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
StatusClassTotals: map[string]uint64{},
Routes: []routeMetricSnapshot{},
}
}
m.mu.RLock()
defer m.mu.RUnlock()
routes := make([]routeMetricSnapshot, 0, len(m.buckets))
for _, bucket := range m.buckets {
avgMs := 0.0
if bucket.Count > 0 {
avgMs = (bucket.TotalLatencyNanos / float64(bucket.Count)) / float64(time.Millisecond)
}
routes = append(routes, routeMetricSnapshot{
Method: bucket.Method,
Path: bucket.Path,
Status: bucket.Status,
Count: bucket.Count,
AvgLatencyMs: avgMs,
MaxLatencyMs: bucket.MaxLatencyNanos / float64(time.Millisecond),
LastSeenAt: bucket.LastSeenAt.Format(time.RFC3339Nano),
})
}
sort.Slice(routes, func(i, j int) bool {
if routes[i].Method != routes[j].Method {
return routes[i].Method < routes[j].Method
}
if routes[i].Path != routes[j].Path {
return routes[i].Path < routes[j].Path
}
return routes[i].Status < routes[j].Status
})
return metricsSnapshot{
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(time.Since(m.startedAt).Seconds()),
RequestsTotal: m.requestsTotal,
StatusClassTotals: map[string]uint64{
"2xx": m.status2xxTotal,
"3xx": m.status3xxTotal,
"4xx": m.status4xxTotal,
"5xx": m.status5xxTotal,
"other": m.statusOther,
},
Routes: routes,
}
}
func requestMetricsMiddleware(metrics *requestMetrics) gin.HandlerFunc {
return func(c *gin.Context) {
startedAt := time.Now()
c.Next()
path := c.FullPath()
if path == "" {
path = c.Request.URL.Path
}
if path == "/v1/metrics" || path == "/v1/metrics/prometheus" {
return
}
metrics.observe(c.Request.Method, path, c.Writer.Status(), time.Since(startedAt))
}
}
func (m *requestMetrics) snapshotPrometheus() string {
snapshot := m.snapshot()
var builder strings.Builder
builder.WriteString("# HELP productier_http_uptime_seconds Process uptime in seconds.\n")
builder.WriteString("# TYPE productier_http_uptime_seconds gauge\n")
builder.WriteString("productier_http_uptime_seconds ")
builder.WriteString(strconv.FormatInt(snapshot.UptimeSeconds, 10))
builder.WriteByte('\n')
builder.WriteString("# HELP productier_http_requests_total Total HTTP requests by status class.\n")
builder.WriteString("# TYPE productier_http_requests_total counter\n")
statusClasses := []string{"2xx", "3xx", "4xx", "5xx", "other"}
for _, statusClass := range statusClasses {
builder.WriteString(`productier_http_requests_total{status_class="`)
builder.WriteString(escapePrometheusLabelValue(statusClass))
builder.WriteString(`"} `)
builder.WriteString(strconv.FormatUint(snapshot.StatusClassTotals[statusClass], 10))
builder.WriteByte('\n')
}
builder.WriteString("# HELP productier_http_requests_route_total Total HTTP requests by route and status code.\n")
builder.WriteString("# TYPE productier_http_requests_route_total counter\n")
builder.WriteString("# HELP productier_http_request_latency_avg_ms Average request latency in milliseconds by route and status code.\n")
builder.WriteString("# TYPE productier_http_request_latency_avg_ms gauge\n")
builder.WriteString("# HELP productier_http_request_latency_max_ms Max request latency in milliseconds by route and status code.\n")
builder.WriteString("# TYPE productier_http_request_latency_max_ms gauge\n")
for _, route := range snapshot.Routes {
labels := `method="` + escapePrometheusLabelValue(route.Method) +
`",path="` + escapePrometheusLabelValue(route.Path) +
`",status="` + strconv.Itoa(route.Status) + `"`
builder.WriteString("productier_http_requests_route_total{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatUint(route.Count, 10))
builder.WriteByte('\n')
builder.WriteString("productier_http_request_latency_avg_ms{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatFloat(route.AvgLatencyMs, 'f', 3, 64))
builder.WriteByte('\n')
builder.WriteString("productier_http_request_latency_max_ms{")
builder.WriteString(labels)
builder.WriteString("} ")
builder.WriteString(strconv.FormatFloat(route.MaxLatencyMs, 'f', 3, 64))
builder.WriteByte('\n')
}
return builder.String()
}
func escapePrometheusLabelValue(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return escaped
}
func itoa(value int) string {
if value == 0 {
return "0"
}
isNegative := value < 0
if isNegative {
value = -value
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + (value % 10))
value /= 10
}
if isNegative {
index--
digits[index] = '-'
}
return string(digits[index:])
}
@@ -0,0 +1,31 @@
package httpapi
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) authorizeMetricsRequest(c *gin.Context) bool {
expectedToken := strings.TrimSpace(s.metricsToken)
if expectedToken == "" {
return true
}
providedToken := strings.TrimSpace(c.GetHeader("X-Metrics-Token"))
if providedToken == "" {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
providedToken = strings.TrimSpace(authHeader[len("Bearer "):])
}
}
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
s.writeStatusError(c, http.StatusUnauthorized, "valid metrics token required")
return false
}
return true
}
@@ -0,0 +1,66 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestAuthorizeMetricsRequest(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
createContext := func(headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
request := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
for key, value := range headers {
request.Header.Set(key, value)
}
context.Request = request
return context, recorder
}
t.Run("allows when token unset", func(t *testing.T) {
server := &Server{metricsToken: ""}
context, _ := createContext(nil)
if !server.authorizeMetricsRequest(context) {
t.Fatal("expected request to pass when metrics token is unset")
}
})
t.Run("accepts bearer token", func(t *testing.T) {
server := &Server{metricsToken: "strong-metrics-token"}
context, _ := createContext(map[string]string{
"Authorization": "Bearer strong-metrics-token",
})
if !server.authorizeMetricsRequest(context) {
t.Fatal("expected bearer token to authorize request")
}
})
t.Run("accepts x metrics token", func(t *testing.T) {
server := &Server{metricsToken: "strong-metrics-token"}
context, _ := createContext(map[string]string{
"X-Metrics-Token": "strong-metrics-token",
})
if !server.authorizeMetricsRequest(context) {
t.Fatal("expected X-Metrics-Token to authorize request")
}
})
t.Run("rejects invalid token", func(t *testing.T) {
server := &Server{metricsToken: "strong-metrics-token"}
context, recorder := createContext(map[string]string{
"Authorization": "Bearer wrong-token",
})
if server.authorizeMetricsRequest(context) {
t.Fatal("expected invalid token to be rejected")
}
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
})
}
@@ -0,0 +1,157 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestRequestMetricsObserveAndSnapshot(t *testing.T) {
t.Parallel()
metrics := newRequestMetrics()
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 100*time.Millisecond)
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 200*time.Millisecond)
metrics.observe(http.MethodPost, "/v1/tasks", http.StatusCreated, 40*time.Millisecond)
snapshot := metrics.snapshot()
if snapshot.RequestsTotal != 3 {
t.Fatalf("requestsTotal = %d, want 3", snapshot.RequestsTotal)
}
if snapshot.StatusClassTotals["2xx"] != 3 {
t.Fatalf("2xx total = %d, want 3", snapshot.StatusClassTotals["2xx"])
}
if len(snapshot.Routes) != 2 {
t.Fatalf("route bucket count = %d, want 2", len(snapshot.Routes))
}
if snapshot.UptimeSeconds < 0 {
t.Fatalf("uptimeSeconds = %d, want >= 0", snapshot.UptimeSeconds)
}
health := findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/health", http.StatusOK)
if health == nil {
t.Fatal("missing route metric for GET /v1/health 200")
}
if health.Count != 2 {
t.Fatalf("health count = %d, want 2", health.Count)
}
if health.AvgLatencyMs != 150 {
t.Fatalf("health avgLatencyMs = %.2f, want 150", health.AvgLatencyMs)
}
if health.MaxLatencyMs != 200 {
t.Fatalf("health maxLatencyMs = %.2f, want 200", health.MaxLatencyMs)
}
if _, err := time.Parse(time.RFC3339Nano, health.LastSeenAt); err != nil {
t.Fatalf("health lastSeenAt parse error: %v", err)
}
if _, err := time.Parse(time.RFC3339Nano, snapshot.GeneratedAt); err != nil {
t.Fatalf("snapshot generatedAt parse error: %v", err)
}
}
func TestRequestMetricsMiddlewareSkipsMetricsEndpoint(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
metrics := newRequestMetrics()
router := gin.New()
router.Use(requestMetricsMiddleware(metrics))
router.GET("/v1/health", func(c *gin.Context) {
c.Status(http.StatusOK)
})
router.GET("/v1/metrics", func(c *gin.Context) {
c.Status(http.StatusOK)
})
router.GET("/v1/metrics/prometheus", func(c *gin.Context) {
c.Status(http.StatusOK)
})
healthRequest := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
healthResponse := httptest.NewRecorder()
router.ServeHTTP(healthResponse, healthRequest)
if healthResponse.Code != http.StatusOK {
t.Fatalf("GET /v1/health status = %d, want 200", healthResponse.Code)
}
metricsRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics", nil)
metricsResponse := httptest.NewRecorder()
router.ServeHTTP(metricsResponse, metricsRequest)
if metricsResponse.Code != http.StatusOK {
t.Fatalf("GET /v1/metrics status = %d, want 200", metricsResponse.Code)
}
prometheusRequest := httptest.NewRequest(http.MethodGet, "/v1/metrics/prometheus", nil)
prometheusResponse := httptest.NewRecorder()
router.ServeHTTP(prometheusResponse, prometheusRequest)
if prometheusResponse.Code != http.StatusOK {
t.Fatalf("GET /v1/metrics/prometheus status = %d, want 200", prometheusResponse.Code)
}
snapshot := metrics.snapshot()
if snapshot.RequestsTotal != 1 {
t.Fatalf("requestsTotal = %d, want 1", snapshot.RequestsTotal)
}
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics", http.StatusOK) != nil {
t.Fatal("metrics endpoint request should be excluded from tracking")
}
if findRouteMetric(snapshot.Routes, http.MethodGet, "/v1/metrics/prometheus", http.StatusOK) != nil {
t.Fatal("prometheus metrics endpoint request should be excluded from tracking")
}
}
func TestSnapshotPrometheus(t *testing.T) {
t.Parallel()
metrics := newRequestMetrics()
metrics.observe(http.MethodGet, "/v1/health", http.StatusOK, 50*time.Millisecond)
metrics.observe(http.MethodGet, "/v1/tasks", http.StatusNotFound, 25*time.Millisecond)
metrics.observe(http.MethodGet, `/v1/quoted"path`, http.StatusOK, 35*time.Millisecond)
output := metrics.snapshotPrometheus()
expectedFragments := []string{
"productier_http_uptime_seconds",
`productier_http_requests_total{status_class="2xx"} 2`,
`productier_http_requests_total{status_class="4xx"} 1`,
`productier_http_requests_route_total{method="GET",path="/v1/health",status="200"} 1`,
`productier_http_request_latency_avg_ms{method="GET",path="/v1/health",status="200"} 50.000`,
`productier_http_request_latency_max_ms{method="GET",path="/v1/tasks",status="404"} 25.000`,
`path="/v1/quoted\"path"`,
}
for _, fragment := range expectedFragments {
if !strings.Contains(output, fragment) {
t.Fatalf("expected prometheus output to contain %q\noutput:\n%s", fragment, output)
}
}
}
func TestItoa(t *testing.T) {
t.Parallel()
cases := map[int]string{
0: "0",
7: "7",
42: "42",
-10: "-10",
2048: "2048",
}
for input, want := range cases {
if got := itoa(input); got != want {
t.Fatalf("itoa(%d) = %q, want %q", input, got, want)
}
}
}
func findRouteMetric(routes []routeMetricSnapshot, method, path string, status int) *routeMetricSnapshot {
for _, route := range routes {
if route.Method == method && route.Path == path && route.Status == status {
result := route
return &result
}
}
return nil
}
@@ -0,0 +1,212 @@
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"productier/apps/backend/internal/store"
)
// OAuth state for CSRF protection
type oauthState struct {
State string `json:"state"`
Provider string `json:"provider"`
WorkspaceSlug string `json:"workspaceSlug"`
RedirectURL string `json:"redirectUrl"`
}
// In-memory state store (in production, use Redis or database)
var oauthStates = make(map[string]oauthState)
func (s *Server) registerOAuthRoutes(group *gin.RouterGroup) {
// Google Calendar OAuth
group.GET("/oauth/google-calendar/connect", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
state := uuid.NewString()
redirectURL := c.Query("redirect")
if redirectURL == "" {
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
}
oauthStates[state] = oauthState{
State: state,
Provider: "google_calendar",
WorkspaceSlug: workspaceSlug,
RedirectURL: redirectURL,
}
// Build Google OAuth URL
// In production, use actual OAuth credentials from config
authURL := fmt.Sprintf(
"https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s&access_type=offline&prompt=consent",
url.QueryEscape(s.config.GoogleClientID),
url.QueryEscape(s.config.GoogleRedirectURI),
url.QueryEscape("https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly"),
state,
)
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
})
group.GET("/oauth/google-calendar/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
oauthState, exists := oauthStates[state]
if !exists || oauthState.Provider != "google_calendar" {
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
return
}
delete(oauthStates, state)
// Exchange code for tokens
// In production, make actual HTTP request to Google's token endpoint
// For now, we'll create a placeholder integration
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
WorkspaceSlug: oauthState.WorkspaceSlug,
Provider: "google_calendar",
Name: "Google Calendar",
Config: `{"calendar_id": "primary"}`,
Credentials: code, // In production, store actual tokens
})
// Redirect back to the app
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=google_calendar")
})
// Slack OAuth
group.GET("/oauth/slack/connect", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
state := uuid.NewString()
redirectURL := c.Query("redirect")
if redirectURL == "" {
redirectURL = fmt.Sprintf("/app/%s/integrations", workspaceSlug)
}
oauthStates[state] = oauthState{
State: state,
Provider: "slack",
WorkspaceSlug: workspaceSlug,
RedirectURL: redirectURL,
}
// Build Slack OAuth URL
scopes := "chat:write,channels:read,groups:read,im:read"
authURL := fmt.Sprintf(
"https://slack.com/oauth/v2/authorize?client_id=%s&scope=%s&redirect_uri=%s&state=%s",
url.QueryEscape(s.config.SlackClientID),
url.QueryEscape(scopes),
url.QueryEscape(s.config.SlackRedirectURI),
state,
)
c.JSON(http.StatusOK, gin.H{"authUrl": authURL})
})
group.GET("/oauth/slack/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
oauthState, exists := oauthStates[state]
if !exists || oauthState.Provider != "slack" {
s.writeStatusError(c, http.StatusBadRequest, "invalid oauth state")
return
}
delete(oauthStates, state)
// Exchange code for tokens
// In production, make actual HTTP request to Slack's token endpoint
// For now, we'll create a placeholder integration
integration := s.store.CreateIntegration(store.CreateIntegrationInput{
WorkspaceSlug: oauthState.WorkspaceSlug,
Provider: "slack",
Name: "Slack",
Config: `{"channel": "general"}`,
Credentials: code, // In production, store actual tokens
})
// Redirect back to the app
c.Redirect(http.StatusTemporaryRedirect, oauthState.RedirectURL+"?connected=slack&integration_id="+integration.ID)
})
// Disconnect integration
group.POST("/integrations/:integrationId/disconnect", func(c *gin.Context) {
integration, err := s.store.GetIntegrationByID(c.Param("integrationId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, integration.WorkspaceSlug); !ok {
return
}
// In production, revoke OAuth tokens with the provider
// For Google: https://oauth2.googleapis.com/revoke?token=...
// For Slack: https://slack.com/api/auth.revoke?token=...
if err := s.store.DeleteIntegration(integration.ID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
// SyncGoogleCalendar syncs events with Google Calendar
func (s *Server) SyncGoogleCalendar(workspaceSlug string) error {
integrations := s.store.ListIntegrations(workspaceSlug)
for _, integration := range integrations {
if integration.Provider == "google_calendar" && integration.Status == "active" {
// In production, use the stored credentials to:
// 1. Fetch events from Google Calendar
// 2. Create/update events in our database
// 3. Push local events to Google Calendar
// This would be done via the Google Calendar API client
}
}
return nil
}
// SendSlackNotification sends a notification to Slack
func (s *Server) SendSlackNotification(workspaceSlug, channel, message string) error {
integrations := s.store.ListIntegrations(workspaceSlug)
for _, integration := range integrations {
if integration.Provider == "slack" && integration.Status == "active" {
// Parse config to get channel
var config struct {
Channel string `json:"channel"`
}
if err := json.Unmarshal([]byte(integration.Config), &config); err != nil {
continue
}
// In production, use the stored credentials to:
// 1. Post message to Slack channel via webhook or API
// This would be done via the Slack API client
}
}
return nil
}
// Helper to parse JSON config
func parseConfig(configStr string) map[string]interface{} {
var config map[string]interface{}
if err := json.NewDecoder(strings.NewReader(configStr)).Decode(&config); err != nil {
return make(map[string]interface{})
}
return config
}
@@ -0,0 +1,132 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/store"
)
func (s *Server) registerProductivityRoutes(group *gin.RouterGroup) {
// Inbox
group.GET("/inbox", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInboxItems(workspaceSlug)})
})
group.POST("/inbox", func(c *gin.Context) {
var input store.CreateInboxItemInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
item := s.store.CreateInboxItem(input)
c.JSON(http.StatusCreated, gin.H{"data": item})
})
group.POST("/inbox/:itemId/process", func(c *gin.Context) {
var input struct {
EntityType string `json:"entityType" binding:"required"`
EntityID string `json:"entityId" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if err := s.store.ProcessInboxItem(c.Param("itemId"), input.EntityType, input.EntityID); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
group.DELETE("/inbox/:itemId", func(c *gin.Context) {
if err := s.store.DeleteInboxItem(c.Param("itemId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Time entries
group.GET("/time-entries", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTimeEntries(workspaceSlug)})
})
group.POST("/time-entries", func(c *gin.Context) {
var input store.CreateTimeEntryInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
entry := s.store.CreateTimeEntry(input)
c.JSON(http.StatusCreated, gin.H{"data": entry})
})
group.PATCH("/time-entries/:entryId", func(c *gin.Context) {
var input store.UpdateTimeEntryInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateTimeEntry(c.Param("entryId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
group.DELETE("/time-entries/:entryId", func(c *gin.Context) {
if err := s.store.DeleteTimeEntry(c.Param("entryId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// Saved views
group.GET("/saved-views", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
entityType := c.Query("entityType")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListSavedViews(workspaceSlug, entityType)})
})
group.POST("/saved-views", func(c *gin.Context) {
var input store.CreateSavedViewInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
view := s.store.CreateSavedView(input)
c.JSON(http.StatusCreated, gin.H{"data": view})
})
group.DELETE("/saved-views/:viewId", func(c *gin.Context) {
if err := s.store.DeleteSavedView(c.Param("viewId")); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
+570
View File
@@ -0,0 +1,570 @@
package httpapi
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"productier/apps/backend/internal/authsession"
"productier/apps/backend/internal/store"
)
const sessionContextKey = "sessionUser"
func (s *Server) registerRoutes() {
v1 := s.engine.Group("/v1")
{
v1.GET("/health", func(c *gin.Context) {
now := time.Now().UTC()
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
storageStatus := gin.H{
"provider": s.files.Provider(),
"ok": true,
}
if err := s.files.Probe(probeCtx); err != nil {
storageStatus["ok"] = false
storageStatus["error"] = err.Error()
c.JSON(http.StatusServiceUnavailable, gin.H{
"ok": false,
"mode": s.mode,
"timestamp": now,
"storage": storageStatus,
})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"mode": s.mode,
"timestamp": now,
"storage": storageStatus,
})
})
v1.GET("/metrics", func(c *gin.Context) {
if !s.authorizeMetricsRequest(c) {
return
}
c.JSON(http.StatusOK, s.metrics.snapshot())
})
v1.GET("/metrics/prometheus", func(c *gin.Context) {
if !s.authorizeMetricsRequest(c) {
return
}
c.Data(http.StatusOK, "text/plain; version=0.0.4; charset=utf-8", []byte(s.metrics.snapshotPrometheus()))
})
v1.GET("/invites/:token", func(c *gin.Context) {
invite, err := s.store.GetInviteByToken(c.Param("token"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": invite})
})
}
authorized := v1.Group("/")
authorized.Use(s.requireSession())
{
authorized.GET("/workspaces", func(c *gin.Context) {
user := s.sessionUser(c)
workspaces := s.store.ListWorkspaces()
visible := make([]store.Workspace, 0, len(workspaces))
for _, workspace := range workspaces {
if _, ok := s.requireWorkspaceMemberByEmail(workspace.Slug, user.Email); ok {
visible = append(visible, workspace)
}
}
c.JSON(http.StatusOK, gin.H{"data": visible})
})
authorized.GET("/members", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListMembers(workspaceSlug)})
})
authorized.PATCH("/members/:memberId", func(c *gin.Context) {
member, err := s.store.GetMemberByID(c.Param("memberId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
actingMember, ok := s.requireWorkspaceMember(c, member.WorkspaceSlug)
if !ok {
return
}
if actingMember.Role != "owner" && actingMember.Role != "admin" {
s.writeStatusError(c, http.StatusForbidden, "member management permissions required")
return
}
var input store.UpdateMemberInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateMember(member.ID, input)
if err != nil {
switch err.Error() {
case "invalid member role", "invalid member status":
s.writeStatusError(c, http.StatusBadRequest, err.Error())
case "workspace must have at least one active owner":
s.writeStatusError(c, http.StatusConflict, err.Error())
default:
s.writeStatusError(c, http.StatusNotFound, err.Error())
}
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/invites", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListInvites(workspaceSlug)})
})
authorized.POST("/invites", func(c *gin.Context) {
var input store.CreateInviteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
member, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug)
if !ok {
return
}
if member.Role != "owner" && member.Role != "admin" {
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateInvite(input)})
})
authorized.POST("/invites/:token/revoke", func(c *gin.Context) {
invite, err := s.store.GetInviteByID(c.Param("token"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
member, ok := s.requireWorkspaceMember(c, invite.WorkspaceSlug)
if !ok {
return
}
if member.Role != "owner" && member.Role != "admin" {
s.writeStatusError(c, http.StatusForbidden, "invite permissions required")
return
}
if err := s.store.RevokeInvite(invite.ID); err != nil {
switch err.Error() {
case "only pending invites can be revoked":
s.writeStatusError(c, http.StatusConflict, err.Error())
default:
s.writeStatusError(c, http.StatusNotFound, err.Error())
}
return
}
c.Status(http.StatusNoContent)
})
authorized.POST("/invites/:token/accept", func(c *gin.Context) {
var input store.AcceptInviteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
user := s.sessionUser(c)
invite, err := s.store.AcceptInvite(c.Param("token"), store.AcceptInviteInput{
Name: user.Name,
Email: user.Email,
})
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": invite})
})
authorized.GET("/activity", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if strings.TrimSpace(workspaceSlug) == "" {
s.writeStatusError(c, http.StatusBadRequest, "workspaceSlug is required")
return
}
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
params, err := parseActivityListParams(c.Query("limit"), c.Query("type"), c.Query("q"))
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
activities := s.store.ListActivities(workspaceSlug)
c.JSON(http.StatusOK, gin.H{"data": filterActivityEntries(activities, params)})
})
authorized.GET("/board-groups", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListBoardGroups(workspaceSlug)})
})
authorized.POST("/board-groups", func(c *gin.Context) {
var input store.CreateBoardGroupInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateBoardGroup(input)})
})
authorized.PATCH("/board-groups/:groupId", func(c *gin.Context) {
group, err := s.store.GetBoardGroupByID(c.Param("groupId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, group.WorkspaceSlug); !ok {
return
}
var input store.UpdateBoardGroupInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateBoardGroup(c.Param("groupId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/labels", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListLabels(workspaceSlug)})
})
authorized.POST("/labels", func(c *gin.Context) {
var input store.CreateLabelInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateLabel(input)})
})
authorized.GET("/tasks", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListTasks(workspaceSlug)})
})
authorized.POST("/tasks", func(c *gin.Context) {
var input store.CreateTaskInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
task := s.store.CreateTask(input)
// Trigger webhooks for task creation
s.store.TriggerWebhooks(input.WorkspaceSlug, "task.created", map[string]interface{}{
"taskId": task.ID,
"title": task.Title,
})
c.JSON(http.StatusCreated, gin.H{"data": task})
})
authorized.PATCH("/tasks/:taskId", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
var input store.UpdateTaskInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
// Check if assignee is being set (task assignment notification)
if input.AssigneeID != nil && *input.AssigneeID != "" {
// Get assignee email from member ID
members := s.store.ListMembers(task.WorkspaceSlug)
for _, member := range members {
if member.ID == *input.AssigneeID && member.Status == "active" {
// Create notification for the assignee
s.store.CreateNotificationForTaskAssignment(
task.WorkspaceSlug,
member.Email,
task.Title,
task.ID,
)
break
}
}
}
// Check if status is being changed to done (task completion notification)
if input.Status != nil && *input.Status == "done" && task.Status != "done" && task.AssigneeID != nil {
// Notify the task creator or workspace owner
members := s.store.ListMembers(task.WorkspaceSlug)
for _, member := range members {
if member.Role == "owner" || member.Role == "admin" {
s.store.CreateNotificationForTaskCompletion(
task.WorkspaceSlug,
member.Email,
task.Title,
task.ID,
)
break
}
}
}
updated, err := s.store.UpdateTask(c.Param("taskId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
// Trigger webhooks for task updates
s.store.TriggerWebhooks(task.WorkspaceSlug, "task.updated", map[string]interface{}{
"taskId": task.ID,
"title": task.Title,
})
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/calendar/events", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListEvents(workspaceSlug)})
})
authorized.POST("/calendar/events", func(c *gin.Context) {
var input store.CreateEventInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateEvent(input)})
})
authorized.PATCH("/calendar/events/:eventId", func(c *gin.Context) {
event, err := s.store.GetEventByID(c.Param("eventId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, event.WorkspaceSlug); !ok {
return
}
var input store.UpdateEventInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateEvent(c.Param("eventId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/notes", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListNotes(workspaceSlug)})
})
authorized.POST("/notes", func(c *gin.Context) {
var input store.CreateNoteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateNote(input)})
})
authorized.PATCH("/notes/:noteId", func(c *gin.Context) {
note, err := s.store.GetNoteByID(c.Param("noteId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, note.WorkspaceSlug); !ok {
return
}
var input store.UpdateNoteInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateNote(c.Param("noteId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
authorized.GET("/focus/sessions", func(c *gin.Context) {
workspaceSlug := c.Query("workspaceSlug")
if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok {
return
}
c.JSON(http.StatusOK, gin.H{"data": s.store.ListFocusSessions(workspaceSlug)})
})
authorized.POST("/focus/sessions", func(c *gin.Context) {
var input store.CreateFocusSessionInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok {
return
}
c.JSON(http.StatusCreated, gin.H{"data": s.store.CreateFocusSession(input)})
})
authorized.PATCH("/focus/sessions/:sessionId", func(c *gin.Context) {
session, err := s.store.GetFocusSessionByID(c.Param("sessionId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, session.WorkspaceSlug); !ok {
return
}
var input store.UpdateFocusSessionInput
if err := c.ShouldBindJSON(&input); err != nil {
s.writeStatusError(c, http.StatusBadRequest, err.Error())
return
}
updated, err := s.store.UpdateFocusSession(c.Param("sessionId"), input)
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
})
s.registerTaskAttachmentRoutes(authorized)
s.registerMailRoutes(authorized)
s.registerCRMRoutes(authorized)
s.registerProductivityRoutes(authorized)
s.registerIntegrationRoutes(authorized)
s.registerOAuthRoutes(authorized)
}
}
func (s *Server) requireSession() gin.HandlerFunc {
return func(c *gin.Context) {
user, err := s.authClient.GetUser(c.Request.Context(), c.GetHeader("Cookie"))
if err != nil {
s.writeStatusError(c, http.StatusUnauthorized, "session lookup failed")
c.Abort()
return
}
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
c.Abort()
return
}
c.Set(sessionContextKey, user)
c.Next()
}
}
func (s *Server) sessionUser(c *gin.Context) *authsession.User {
value, exists := c.Get(sessionContextKey)
if !exists {
return nil
}
user, _ := value.(*authsession.User)
return user
}
func (s *Server) requireWorkspaceMember(c *gin.Context, workspaceSlug string) (store.Member, bool) {
user := s.sessionUser(c)
if user == nil {
s.writeStatusError(c, http.StatusUnauthorized, "authentication required")
return store.Member{}, false
}
member, ok := s.requireWorkspaceMemberByEmail(workspaceSlug, user.Email)
if !ok {
s.writeStatusError(c, http.StatusForbidden, "workspace membership required")
return store.Member{}, false
}
return member, true
}
func (s *Server) requireWorkspaceMemberByEmail(workspaceSlug string, email string) (store.Member, bool) {
for _, member := range s.store.ListMembers(workspaceSlug) {
if strings.EqualFold(member.Email, email) && member.Status == "active" {
return member, true
}
}
return store.Member{}, false
}
+96
View File
@@ -0,0 +1,96 @@
package httpapi
import (
"net/http"
"os"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"productier/apps/backend/internal/authsession"
"productier/apps/backend/internal/filestorage"
"productier/apps/backend/internal/mailruntime"
"productier/apps/backend/internal/store"
)
func getEnvOrDefault(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
type OAuthConfig struct {
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURI string
SlackClientID string
SlackClientSecret string
SlackRedirectURI string
}
type Server struct {
engine *gin.Engine
mode string
store store.Store
authClient *authsession.Client
mail *mailruntime.Service
files filestorage.Storage
metrics *requestMetrics
metricsToken string
config OAuthConfig
}
func NewServer(
dataStore store.Store,
authClient *authsession.Client,
mailService *mailruntime.Service,
fileStorage filestorage.Storage,
mode string,
corsAllowOrigins []string,
metricsToken string,
logger *zap.Logger,
) *Server {
engine := gin.New()
metrics := newRequestMetrics()
engine.Use(gin.Recovery())
engine.Use(requestMetricsMiddleware(metrics))
engine.Use(requestLogMiddleware(logger))
engine.Use(requestIDMiddleware())
engine.Use(cors.New(cors.Config{
AllowOrigins: corsAllowOrigins,
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodOptions},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "Cookie"},
ExposeHeaders: []string{"Content-Length", "X-Request-Id"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
server := &Server{
engine: engine,
mode: mode,
store: dataStore,
authClient: authClient,
mail: mailService,
files: fileStorage,
metrics: metrics,
metricsToken: metricsToken,
config: OAuthConfig{
GoogleClientID: getEnvOrDefault("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnvOrDefault("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURI: getEnvOrDefault("GOOGLE_REDIRECT_URI", "http://localhost:8080/v1/oauth/google-calendar/callback"),
SlackClientID: getEnvOrDefault("SLACK_CLIENT_ID", ""),
SlackClientSecret: getEnvOrDefault("SLACK_CLIENT_SECRET", ""),
SlackRedirectURI: getEnvOrDefault("SLACK_REDIRECT_URI", "http://localhost:8080/v1/oauth/slack/callback"),
},
}
server.registerRoutes()
return server
}
func (s *Server) Engine() *gin.Engine {
return s.engine
}
@@ -0,0 +1,173 @@
package httpapi
import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"productier/apps/backend/internal/filestorage"
"productier/apps/backend/internal/store"
)
const maxTaskAttachmentBytes int64 = 20 << 20 // 20 MB
func (s *Server) registerTaskAttachmentRoutes(group *gin.RouterGroup) {
group.POST("/tasks/:taskId/attachments", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
file, err := c.FormFile("file")
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, "file is required")
return
}
if file.Size <= 0 {
s.writeStatusError(c, http.StatusBadRequest, "file is empty")
return
}
if file.Size > maxTaskAttachmentBytes {
s.writeStatusError(c, http.StatusBadRequest, "file exceeds 20MB limit")
return
}
attachmentID := uuid.NewString()
objectKey := taskAttachmentObjectKey(task.ID, attachmentID)
src, err := file.Open()
if err != nil {
s.writeStatusError(c, http.StatusBadRequest, "unable to read uploaded file")
return
}
defer src.Close()
mimeType := file.Header.Get("Content-Type")
if strings.TrimSpace(mimeType) == "" {
mimeType = "application/octet-stream"
}
if err := s.files.Put(c.Request.Context(), objectKey, src, mimeType, file.Size); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, "failed to store uploaded file")
return
}
attachment := store.Attachment{
ID: attachmentID,
Name: cleanAttachmentName(file.Filename),
MimeType: mimeType,
Size: int(file.Size),
DataURL: fmt.Sprintf("/v1/tasks/%s/attachments/%s/download", task.ID, attachmentID),
}
attachments := append([]store.Attachment{attachment}, task.Attachments...)
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: attachments}); err != nil {
_ = s.files.Delete(c.Request.Context(), objectKey)
s.writeStatusError(c, http.StatusInternalServerError, "failed to save task attachment")
return
}
c.JSON(http.StatusCreated, gin.H{"data": attachment})
})
group.GET("/tasks/:taskId/attachments/:attachmentId/download", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
attachmentID := c.Param("attachmentId")
var attachment *store.Attachment
for index := range task.Attachments {
if task.Attachments[index].ID == attachmentID {
attachment = &task.Attachments[index]
break
}
}
if attachment == nil {
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
return
}
object, err := s.files.Get(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID))
if err != nil {
if err == filestorage.ErrNotFound {
s.writeStatusError(c, http.StatusNotFound, "attachment file not found")
return
}
s.writeStatusError(c, http.StatusInternalServerError, "failed to read attachment file")
return
}
defer object.Body.Close()
c.Header("Content-Type", firstNonBlank(object.ContentType, attachment.MimeType, "application/octet-stream"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", cleanAttachmentName(attachment.Name)))
c.Status(http.StatusOK)
if _, err := io.Copy(c.Writer, object.Body); err != nil {
c.Status(http.StatusInternalServerError)
return
}
})
group.DELETE("/tasks/:taskId/attachments/:attachmentId", func(c *gin.Context) {
task, err := s.store.GetTaskByID(c.Param("taskId"))
if err != nil {
s.writeStatusError(c, http.StatusNotFound, err.Error())
return
}
if _, ok := s.requireWorkspaceMember(c, task.WorkspaceSlug); !ok {
return
}
attachmentID := c.Param("attachmentId")
index := -1
for i := range task.Attachments {
if task.Attachments[i].ID == attachmentID {
index = i
break
}
}
if index < 0 {
s.writeStatusError(c, http.StatusNotFound, "attachment not found")
return
}
nextAttachments := make([]store.Attachment, 0, len(task.Attachments)-1)
nextAttachments = append(nextAttachments, task.Attachments[:index]...)
nextAttachments = append(nextAttachments, task.Attachments[index+1:]...)
if _, err := s.store.UpdateTask(task.ID, store.UpdateTaskInput{Attachments: nextAttachments}); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, "failed to update task attachments")
return
}
if err := s.files.Delete(c.Request.Context(), taskAttachmentObjectKey(task.ID, attachmentID)); err != nil {
s.writeStatusError(c, http.StatusInternalServerError, "attachment metadata updated but file cleanup failed")
return
}
c.Status(http.StatusNoContent)
})
}
func taskAttachmentObjectKey(taskID string, attachmentID string) string {
return fmt.Sprintf("tasks/%s/%s", taskID, attachmentID)
}
func cleanAttachmentName(name string) string {
cleaned := strings.TrimSpace(filepath.Base(name))
if cleaned == "" || cleaned == "." || cleaned == "/" {
return "attachment"
}
return cleaned
}
@@ -0,0 +1,876 @@
package mailruntime
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
stdmail "net/mail"
"net/smtp"
"regexp"
"sort"
"strings"
"time"
"github.com/emersion/go-imap"
imapclient "github.com/emersion/go-imap/client"
"github.com/emersion/go-message"
gomail "github.com/emersion/go-message/mail"
"go.uber.org/zap"
"productier/apps/backend/internal/store"
)
var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
type Service struct {
store store.Store
logger *zap.Logger
aead cipher.AEAD
}
type ConnectMailboxInput struct {
WorkspaceSlug string
Label string
Email string
DisplayName string
IMAPHost string
IMAPPort int
IMAPUsername string
IMAPPassword string
IMAPUseTLS bool
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPUseTLS bool
}
type QueueOutgoingMailInput struct {
WorkspaceSlug string
MailboxID string
To []store.MailAddress
Cc []store.MailAddress
Bcc []store.MailAddress
Subject string
TextBody string
HTMLBody string
ScheduledFor *time.Time
}
func New(dataStore store.Store, logger *zap.Logger, secretSeed string) (*Service, error) {
if logger == nil {
logger = zap.NewNop()
}
aead, err := newAEAD(secretSeed)
if err != nil {
return nil, err
}
return &Service{
store: dataStore,
logger: logger,
aead: aead,
}, nil
}
func (s *Service) Start(ctx context.Context) {
go s.runDueOutgoingLoop(ctx)
go s.runMailboxSyncLoop(ctx)
}
func (s *Service) ConnectMailbox(ctx context.Context, input ConnectMailboxInput) (store.Mailbox, error) {
input.normalize()
if err := input.validate(); err != nil {
return store.Mailbox{}, err
}
if err := s.verifyIMAP(ctx, input); err != nil {
return store.Mailbox{}, fmt.Errorf("verify imap connection: %w", err)
}
if err := s.verifySMTP(input); err != nil {
return store.Mailbox{}, fmt.Errorf("verify smtp connection: %w", err)
}
imapCiphertext, err := s.encrypt(input.IMAPPassword)
if err != nil {
return store.Mailbox{}, err
}
smtpCiphertext, err := s.encrypt(input.SMTPPassword)
if err != nil {
return store.Mailbox{}, err
}
mailbox, err := s.store.CreateMailbox(store.CreateMailboxRecordInput{
WorkspaceSlug: input.WorkspaceSlug,
Label: input.Label,
Email: input.Email,
DisplayName: input.DisplayName,
IMAPHost: input.IMAPHost,
IMAPPort: input.IMAPPort,
IMAPUsername: input.IMAPUsername,
IMAPPasswordCiphertext: imapCiphertext,
IMAPUseTLS: input.IMAPUseTLS,
SMTPHost: input.SMTPHost,
SMTPPort: input.SMTPPort,
SMTPUsername: input.SMTPUsername,
SMTPPasswordCiphertext: smtpCiphertext,
SMTPUseTLS: input.SMTPUseTLS,
})
if err != nil {
return store.Mailbox{}, err
}
if syncErr := s.SyncMailbox(ctx, mailbox.ID); syncErr != nil {
s.logger.Warn("initial mailbox sync failed", zap.String("mailboxId", mailbox.ID), zap.Error(syncErr))
}
return mailbox, nil
}
func (s *Service) QueueOutgoingMail(ctx context.Context, input QueueOutgoingMailInput) (store.OutgoingMail, error) {
if input.WorkspaceSlug == "" || input.MailboxID == "" {
return store.OutgoingMail{}, errors.New("workspace and mailbox are required")
}
if len(input.To) == 0 {
return store.OutgoingMail{}, errors.New("at least one recipient is required")
}
status := "queued"
if input.ScheduledFor != nil && input.ScheduledFor.After(time.Now().UTC()) {
status = "scheduled"
}
item, err := s.store.CreateOutgoingMail(store.CreateOutgoingMailInput{
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: input.MailboxID,
To: input.To,
Cc: input.Cc,
Bcc: input.Bcc,
Subject: input.Subject,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
Status: status,
ScheduledFor: input.ScheduledFor,
})
if err != nil {
return store.OutgoingMail{}, err
}
if status == "queued" {
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
updated, getErr := s.store.GetOutgoingMailByID(item.ID)
if getErr == nil {
return updated, err
}
return item, err
}
return s.store.GetOutgoingMailByID(item.ID)
}
return item, nil
}
func (s *Service) SendOutgoingMail(ctx context.Context, outgoingMailID string) error {
item, err := s.store.GetOutgoingMailByID(outgoingMailID)
if err != nil {
return err
}
connection, err := s.mailboxConnection(item.MailboxID)
if err != nil {
return err
}
if err := sendSMTPMessage(connection, connection.SMTPPasswordCiphertext, item); err != nil {
message := err.Error()
_, _ = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
Status: "failed",
Error: &message,
})
return err
}
now := time.Now().UTC()
empty := ""
_, err = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
Status: "sent",
SentAt: &now,
Error: &empty,
})
return err
}
func (s *Service) SyncMailbox(ctx context.Context, mailboxID string) error {
if _, err := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{SyncStatus: "syncing"}); err != nil {
return err
}
connection, err := s.mailboxConnection(mailboxID)
if err != nil {
s.markMailboxError(mailboxID, err)
return err
}
messages, err := fetchInboxMessages(connection, connection.IMAPPasswordCiphertext)
if err != nil {
s.markMailboxError(mailboxID, err)
return err
}
if err := s.store.UpsertMailMessages(mailboxID, messages); err != nil {
s.markMailboxError(mailboxID, err)
return err
}
now := time.Now().UTC()
empty := ""
_, err = s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
SyncStatus: "ready",
SyncError: &empty,
LastSyncedAt: &now,
})
return err
}
func (s *Service) runDueOutgoingLoop(ctx context.Context) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
due := s.store.ListDueOutgoingMails(time.Now().UTC(), 20)
for _, item := range due {
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
s.logger.Warn("send outgoing mail", zap.String("outgoingMailId", item.ID), zap.Error(err))
}
}
}
}
}
func (s *Service) runMailboxSyncLoop(ctx context.Context) {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, mailbox := range s.store.ListAllMailboxes() {
if err := s.SyncMailbox(ctx, mailbox.ID); err != nil {
s.logger.Warn("sync mailbox", zap.String("mailboxId", mailbox.ID), zap.Error(err))
}
}
}
}
}
func (s *Service) markMailboxError(mailboxID string, err error) {
message := err.Error()
_, updateErr := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
SyncStatus: "error",
SyncError: &message,
})
if updateErr != nil {
s.logger.Warn("update mailbox sync error", zap.String("mailboxId", mailboxID), zap.Error(updateErr))
}
}
func (s *Service) mailboxConnection(mailboxID string) (store.MailboxConnection, error) {
connection, err := s.store.GetMailboxConnection(mailboxID)
if err != nil {
return store.MailboxConnection{}, err
}
imapPassword, err := s.decrypt(connection.IMAPPasswordCiphertext)
if err != nil {
return store.MailboxConnection{}, err
}
smtpPassword, err := s.decrypt(connection.SMTPPasswordCiphertext)
if err != nil {
return store.MailboxConnection{}, err
}
connection.IMAPPasswordCiphertext = imapPassword
connection.SMTPPasswordCiphertext = smtpPassword
return connection, nil
}
func newAEAD(secretSeed string) (cipher.AEAD, error) {
if secretSeed == "" {
secretSeed = "productier-local-mail-key"
}
key, err := decodeSecretSeed(secretSeed)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func decodeSecretSeed(secretSeed string) ([]byte, error) {
if raw, err := base64.StdEncoding.DecodeString(secretSeed); err == nil && len(raw) == 32 {
return raw, nil
}
sum := sha256.Sum256([]byte(secretSeed))
return sum[:], nil
}
func (s *Service) encrypt(plain string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := s.aead.Seal(nonce, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
func (s *Service) decrypt(ciphertext string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
if len(raw) < s.aead.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce := raw[:s.aead.NonceSize()]
payload := raw[s.aead.NonceSize():]
plain, err := s.aead.Open(nil, nonce, payload, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func (input *ConnectMailboxInput) normalize() {
input.WorkspaceSlug = strings.TrimSpace(input.WorkspaceSlug)
input.Label = strings.TrimSpace(input.Label)
input.Email = strings.TrimSpace(strings.ToLower(input.Email))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.IMAPHost = strings.TrimSpace(input.IMAPHost)
input.SMTPHost = strings.TrimSpace(input.SMTPHost)
if input.IMAPPort == 0 {
input.IMAPPort = 993
}
if input.SMTPPort == 0 {
input.SMTPPort = 587
}
if input.IMAPUsername == "" {
input.IMAPUsername = input.Email
}
if input.SMTPUsername == "" {
input.SMTPUsername = input.IMAPUsername
}
if input.SMTPPassword == "" {
input.SMTPPassword = input.IMAPPassword
}
if input.Label == "" {
input.Label = input.Email
}
}
func (input ConnectMailboxInput) validate() error {
if input.WorkspaceSlug == "" || input.Email == "" {
return errors.New("workspace and email are required")
}
if input.IMAPHost == "" || input.SMTPHost == "" {
return errors.New("imap and smtp hosts are required")
}
if input.IMAPUsername == "" || input.IMAPPassword == "" {
return errors.New("imap credentials are required")
}
if input.SMTPUsername == "" || input.SMTPPassword == "" {
return errors.New("smtp credentials are required")
}
return nil
}
func (s *Service) verifyIMAP(ctx context.Context, input ConnectMailboxInput) error {
client, err := dialAndLoginIMAP(input.IMAPHost, input.IMAPPort, input.IMAPUseTLS, input.IMAPUsername, input.IMAPPassword)
if err != nil {
return err
}
defer client.Logout()
select {
case <-ctx.Done():
return ctx.Err()
default:
}
_, err = client.Select("INBOX", true)
return err
}
func (s *Service) verifySMTP(input ConnectMailboxInput) error {
client, err := dialSMTPClient(input.SMTPHost, input.SMTPPort, input.SMTPUseTLS)
if err != nil {
return err
}
defer func() {
_ = client.Quit()
_ = client.Close()
}()
return smtpAuthenticate(client, input.SMTPHost, input.SMTPUsername, input.SMTPPassword)
}
func fetchInboxMessages(connection store.MailboxConnection, password string) ([]store.InboundMailMessage, error) {
client, err := dialAndLoginIMAP(connection.IMAPHost, connection.IMAPPort, connection.IMAPUseTLS, connection.IMAPUsername, password)
if err != nil {
return nil, err
}
defer client.Logout()
mbox, err := client.Select("INBOX", true)
if err != nil {
return nil, err
}
if mbox.Messages == 0 {
return []store.InboundMailMessage{}, nil
}
uids, err := client.UidSearch(&imap.SearchCriteria{})
if err != nil {
return nil, err
}
if len(uids) == 0 {
return []store.InboundMailMessage{}, nil
}
sort.Slice(uids, func(i int, j int) bool { return uids[i] < uids[j] })
if len(uids) > 50 {
uids = uids[len(uids)-50:]
}
seqset := new(imap.SeqSet)
seqset.AddNum(uids...)
section := &imap.BodySectionName{}
items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags, imap.FetchEnvelope, section.FetchItem()}
ch := make(chan *imap.Message, len(uids))
done := make(chan error, 1)
go func() {
done <- client.UidFetch(seqset, items, ch)
}()
result := make([]store.InboundMailMessage, 0, len(uids))
for msg := range ch {
parsed, err := parseIMAPMessage(connection.WorkspaceSlug, connection.ID, section, msg)
if err != nil {
continue
}
result = append(result, parsed)
}
if err := <-done; err != nil {
return nil, err
}
sort.Slice(result, func(i int, j int) bool { return result[i].ReceivedAt.After(result[j].ReceivedAt) })
return result, nil
}
func parseIMAPMessage(workspaceSlug string, mailboxID string, section *imap.BodySectionName, msg *imap.Message) (store.InboundMailMessage, error) {
if msg == nil {
return store.InboundMailMessage{}, errors.New("nil message")
}
receivedAt := time.Now().UTC()
if msg.Envelope != nil && !msg.Envelope.Date.IsZero() {
receivedAt = msg.Envelope.Date.UTC()
}
parsed := store.InboundMailMessage{
WorkspaceSlug: workspaceSlug,
MailboxID: mailboxID,
RemoteUID: int64(msg.Uid),
Folder: "INBOX",
ReceivedAt: receivedAt,
IsRead: hasFlag(msg.Flags, imap.SeenFlag),
From: addressFromEnvelope(msg.Envelope),
To: addressesFromEnvelope(msg.Envelope, "to"),
Cc: addressesFromEnvelope(msg.Envelope, "cc"),
}
if msg.Envelope != nil {
parsed.Subject = msg.Envelope.Subject
}
body := msg.GetBody(section)
if body == nil {
parsed.Snippet = truncatePlaintext(parsed.Subject, 180)
return parsed, nil
}
reader, err := gomail.CreateReader(body)
if err != nil && !message.IsUnknownCharset(err) {
return store.InboundMailMessage{}, err
}
if headerMessageID, headerErr := reader.Header.MessageID(); headerErr == nil {
parsed.MessageID = headerMessageID
}
if subject, headerErr := reader.Header.Subject(); headerErr == nil && subject != "" {
parsed.Subject = subject
}
if from, headerErr := reader.Header.AddressList("From"); headerErr == nil && len(from) > 0 {
parsed.From = mailAddress(from[0])
}
if to, headerErr := reader.Header.AddressList("To"); headerErr == nil {
parsed.To = toStoreAddresses(to)
}
if cc, headerErr := reader.Header.AddressList("Cc"); headerErr == nil {
parsed.Cc = toStoreAddresses(cc)
}
if date, headerErr := reader.Header.Date(); headerErr == nil && !date.IsZero() {
parsed.ReceivedAt = date.UTC()
}
for {
part, partErr := reader.NextPart()
if errors.Is(partErr, io.EOF) {
break
}
if partErr != nil && !message.IsUnknownCharset(partErr) {
return store.InboundMailMessage{}, partErr
}
if part == nil {
break
}
contentType := ""
switch header := part.Header.(type) {
case *gomail.InlineHeader:
contentType = header.Get("Content-Type")
default:
contentType = part.Header.Get("Content-Type")
}
payload, readErr := io.ReadAll(io.LimitReader(part.Body, 1<<20))
if readErr != nil {
return store.InboundMailMessage{}, readErr
}
switch {
case strings.HasPrefix(strings.ToLower(contentType), "text/plain"):
if parsed.TextBody == "" {
parsed.TextBody = string(payload)
}
case strings.HasPrefix(strings.ToLower(contentType), "text/html"):
if parsed.HTMLBody == "" {
parsed.HTMLBody = string(payload)
}
}
}
if parsed.TextBody == "" && parsed.HTMLBody != "" {
parsed.TextBody = htmlToText(parsed.HTMLBody)
}
parsed.Snippet = truncatePlaintext(firstNonEmpty(parsed.TextBody, parsed.Subject), 240)
return parsed, nil
}
func sendSMTPMessage(connection store.MailboxConnection, password string, outgoing store.OutgoingMail) error {
messageBytes, err := buildOutgoingMessage(connection, outgoing)
if err != nil {
return err
}
client, err := dialSMTPClient(connection.SMTPHost, connection.SMTPPort, connection.SMTPUseTLS)
if err != nil {
return err
}
defer func() {
_ = client.Quit()
_ = client.Close()
}()
if err := smtpAuthenticate(client, connection.SMTPHost, connection.SMTPUsername, password); err != nil {
return err
}
if err := client.Mail(connection.Email); err != nil {
return err
}
for _, recipient := range uniqueRecipients(outgoing) {
if err := client.Rcpt(recipient); err != nil {
return err
}
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write(messageBytes); err != nil {
_ = writer.Close()
return err
}
return writer.Close()
}
func buildOutgoingMessage(connection store.MailboxConnection, outgoing store.OutgoingMail) ([]byte, error) {
var (
header gomail.Header
buffer bytes.Buffer
)
fromName := strings.TrimSpace(connection.DisplayName)
header.SetAddressList("From", []*gomail.Address{{Name: fromName, Address: connection.Email}})
header.SetAddressList("To", toMailAddresses(outgoing.To))
header.SetAddressList("Cc", toMailAddresses(outgoing.Cc))
header.SetAddressList("Bcc", toMailAddresses(outgoing.Bcc))
header.SetDate(time.Now().UTC())
header.SetSubject(firstNonEmpty(outgoing.Subject, "(no subject)"))
_ = header.GenerateMessageIDWithHostname(sanitizeHostname(connection.SMTPHost))
switch {
case outgoing.TextBody != "" && outgoing.HTMLBody != "":
writer, err := gomail.CreateInlineWriter(&buffer, header)
if err != nil {
return nil, err
}
var textHeader gomail.InlineHeader
textHeader.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
textPart, err := writer.CreatePart(textHeader)
if err != nil {
return nil, err
}
if _, err := io.WriteString(textPart, outgoing.TextBody); err != nil {
return nil, err
}
if err := textPart.Close(); err != nil {
return nil, err
}
var htmlHeader gomail.InlineHeader
htmlHeader.SetContentType("text/html", map[string]string{"charset": "utf-8"})
htmlPart, err := writer.CreatePart(htmlHeader)
if err != nil {
return nil, err
}
if _, err := io.WriteString(htmlPart, outgoing.HTMLBody); err != nil {
return nil, err
}
if err := htmlPart.Close(); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
case outgoing.HTMLBody != "":
header.SetContentType("text/html", map[string]string{"charset": "utf-8"})
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
if err != nil {
return nil, err
}
if _, err := io.WriteString(writer, outgoing.HTMLBody); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
default:
header.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
if err != nil {
return nil, err
}
if _, err := io.WriteString(writer, outgoing.TextBody); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
}
return buffer.Bytes(), nil
}
func dialAndLoginIMAP(host string, port int, useTLS bool, username string, password string) (*imapclient.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
var (
client *imapclient.Client
err error
)
if useTLS {
client, err = imapclient.DialTLS(addr, &tls.Config{ServerName: host})
} else {
client, err = imapclient.Dial(addr)
}
if err != nil {
return nil, err
}
if err := client.Login(username, password); err != nil {
_ = client.Logout()
return nil, err
}
return client, nil
}
func dialSMTPClient(host string, port int, useTLS bool) (*smtp.Client, error) {
addr := fmt.Sprintf("%s:%d", host, port)
if useTLS && port == 465 {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return nil, err
}
return smtp.NewClient(conn, host)
}
client, err := smtp.Dial(addr)
if err != nil {
return nil, err
}
if useTLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil {
_ = client.Close()
return nil, err
}
}
}
return client, nil
}
func smtpAuthenticate(client *smtp.Client, host string, username string, password string) error {
if username == "" || password == "" {
return nil
}
if ok, _ := client.Extension("AUTH"); !ok {
return nil
}
return client.Auth(smtp.PlainAuth("", username, password, host))
}
func hasFlag(flags []string, target string) bool {
for _, flag := range flags {
if flag == target {
return true
}
}
return false
}
func addressFromEnvelope(envelope *imap.Envelope) store.MailAddress {
if envelope == nil || len(envelope.From) == 0 {
return store.MailAddress{}
}
address := envelope.From[0]
return store.MailAddress{
Name: address.PersonalName,
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
}
}
func addressesFromEnvelope(envelope *imap.Envelope, field string) []store.MailAddress {
if envelope == nil {
return []store.MailAddress{}
}
var source []*imap.Address
switch field {
case "to":
source = envelope.To
case "cc":
source = envelope.Cc
}
items := make([]store.MailAddress, 0, len(source))
for _, address := range source {
items = append(items, store.MailAddress{
Name: address.PersonalName,
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
})
}
return items
}
func toStoreAddresses(addrs []*gomail.Address) []store.MailAddress {
items := make([]store.MailAddress, 0, len(addrs))
for _, addr := range addrs {
items = append(items, store.MailAddress{Name: addr.Name, Email: addr.Address})
}
return items
}
func toMailAddresses(addrs []store.MailAddress) []*gomail.Address {
items := make([]*gomail.Address, 0, len(addrs))
for _, addr := range addrs {
if addr.Email == "" {
continue
}
items = append(items, &gomail.Address{Name: addr.Name, Address: addr.Email})
}
return items
}
func uniqueRecipients(outgoing store.OutgoingMail) []string {
set := make(map[string]struct{})
items := make([]string, 0, len(outgoing.To)+len(outgoing.Cc)+len(outgoing.Bcc))
for _, group := range [][]store.MailAddress{outgoing.To, outgoing.Cc, outgoing.Bcc} {
for _, addr := range group {
email := strings.TrimSpace(strings.ToLower(addr.Email))
if email == "" {
continue
}
if _, exists := set[email]; exists {
continue
}
set[email] = struct{}{}
items = append(items, email)
}
}
return items
}
func htmlToText(value string) string {
return strings.TrimSpace(htmlTagPattern.ReplaceAllString(value, " "))
}
func truncatePlaintext(value string, limit int) string {
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if len(value) <= limit {
return value
}
return strings.TrimSpace(value[:limit]) + "…"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func sanitizeHostname(host string) string {
parsedHost := strings.TrimSpace(host)
if parsedHost == "" {
return "localhost"
}
if strings.Contains(parsedHost, ":") {
if h, _, err := strings.Cut(parsedHost, ":"); err && h != "" {
return h
}
}
return parsedHost
}
func mailAddress(addr *stdmail.Address) store.MailAddress {
if addr == nil {
return store.MailAddress{}
}
return store.MailAddress{Name: addr.Name, Email: addr.Address}
}
@@ -0,0 +1,60 @@
package mailruntime
import (
"strings"
"testing"
"productier/apps/backend/internal/store"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
service, err := New(store.NewSeededState("test"), nil, "mailruntime-test-secret")
if err != nil {
t.Fatalf("New() error = %v", err)
}
ciphertext, err := service.encrypt("super-secret-password")
if err != nil {
t.Fatalf("encrypt() error = %v", err)
}
plain, err := service.decrypt(ciphertext)
if err != nil {
t.Fatalf("decrypt() error = %v", err)
}
if plain != "super-secret-password" {
t.Fatalf("decrypt() = %q, want %q", plain, "super-secret-password")
}
}
func TestBuildOutgoingMessageIncludesHeaders(t *testing.T) {
messageBytes, err := buildOutgoingMessage(store.MailboxConnection{
Mailbox: store.Mailbox{
Email: "sender@example.com",
DisplayName: "Sender",
SMTPHost: "smtp.example.com",
},
}, store.OutgoingMail{
To: []store.MailAddress{
{Name: "Recipient", Email: "recipient@example.com"},
},
Subject: "Quarterly Update",
TextBody: "Plain body",
})
if err != nil {
t.Fatalf("buildOutgoingMessage() error = %v", err)
}
message := string(messageBytes)
for _, fragment := range []string{
"Subject: Quarterly Update",
`From: "Sender" <sender@example.com>`,
`To: "Recipient" <recipient@example.com>`,
"Plain body",
} {
if !strings.Contains(message, fragment) {
t.Fatalf("built message missing %q\n%s", fragment, message)
}
}
}
+166
View File
@@ -0,0 +1,166 @@
package store
import "time"
// Contact represents a person in the CRM
type Contact struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
Phone string `json:"phone"`
CompanyID *string `json:"companyId,omitempty"`
CompanyName string `json:"companyName,omitempty"`
Title string `json:"title"`
Notes string `json:"notes"`
AvatarURL string `json:"avatarUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Company represents an organization in the CRM
type Company struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Name string `json:"name"`
Domain string `json:"domain"`
Website string `json:"website"`
Industry string `json:"industry"`
Size string `json:"size"`
Notes string `json:"notes"`
LogoURL string `json:"logoUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ContactTaskLink links a contact to a task
type ContactTaskLink struct {
ID string `json:"id"`
ContactID string `json:"contactId"`
TaskID string `json:"taskId"`
CreatedAt time.Time `json:"createdAt"`
}
// ContactEventLink links a contact to an event
type ContactEventLink struct {
ID string `json:"id"`
ContactID string `json:"contactId"`
EventID string `json:"eventId"`
CreatedAt time.Time `json:"createdAt"`
}
// InboxItem represents a quick capture item
type InboxItem struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Content string `json:"content"`
Source string `json:"source"`
Processed bool `json:"processed"`
ProcessedAt *time.Time `json:"processedAt,omitempty"`
ProcessedEntityType *string `json:"processedEntityType,omitempty"`
ProcessedEntityID *string `json:"processedEntityId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// TimeEntry represents logged time
type TimeEntry struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
TaskID *string `json:"taskId,omitempty"`
Description string `json:"description"`
StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
DurationSeconds int `json:"durationSeconds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// SavedView represents a user's saved filter/view
type SavedView struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Name string `json:"name"`
EntityType string `json:"entityType"`
FilterJSON string `json:"filterJson"`
SortJSON string `json:"sortJson"`
IsDefault bool `json:"isDefault"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// CreateContactInput for creating contacts
type CreateContactInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
Phone string `json:"phone"`
CompanyID *string `json:"companyId"`
Title string `json:"title"`
Notes string `json:"notes"`
}
// UpdateContactInput for updating contacts
type UpdateContactInput struct {
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
Email *string `json:"email"`
Phone *string `json:"phone"`
CompanyID *string `json:"companyId"`
Title *string `json:"title"`
Notes *string `json:"notes"`
}
// CreateCompanyInput for creating companies
type CreateCompanyInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Name string `json:"name" binding:"required"`
Domain string `json:"domain"`
Website string `json:"website"`
Industry string `json:"industry"`
Size string `json:"size"`
Notes string `json:"notes"`
}
// UpdateCompanyInput for updating companies
type UpdateCompanyInput struct {
Name *string `json:"name"`
Domain *string `json:"domain"`
Website *string `json:"website"`
Industry *string `json:"industry"`
Size *string `json:"size"`
Notes *string `json:"notes"`
}
// CreateInboxItemInput for quick capture
type CreateInboxItemInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Content string `json:"content" binding:"required"`
Source string `json:"source"`
}
// CreateTimeEntryInput for time tracking
type CreateTimeEntryInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
TaskID *string `json:"taskId"`
Description string `json:"description"`
StartedAt time.Time `json:"startedAt" binding:"required"`
EndedAt *time.Time `json:"endedAt"`
}
// UpdateTimeEntryInput for updating time entries
type UpdateTimeEntryInput struct {
Description *string `json:"description"`
EndedAt *time.Time `json:"endedAt"`
}
// CreateSavedViewInput for saved views
type CreateSavedViewInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Name string `json:"name" binding:"required"`
EntityType string `json:"entityType" binding:"required"`
FilterJSON string `json:"filterJson"`
SortJSON string `json:"sortJson"`
IsDefault bool `json:"isDefault"`
}
+568
View File
@@ -0,0 +1,568 @@
package store
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
// Contact methods
func (s *PostgresStore) ListContacts(workspaceSlug string) []Contact {
rows, err := s.db.Query(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
LEFT JOIN companies co ON c.company_id = co.id
WHERE c.workspace_slug = $1
ORDER BY c.updated_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
var companyID sql.NullString
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
c.CompanyID = nullStringToPtr(companyID)
contacts = append(contacts, c)
}
return contacts
}
func (s *PostgresStore) GetContactByID(contactID string) (Contact, error) {
var c Contact
var companyID sql.NullString
err := s.db.QueryRow(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
LEFT JOIN companies co ON c.company_id = co.id
WHERE c.id = $1
`, contactID).Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt)
if err != nil {
return c, err
}
c.CompanyID = nullStringToPtr(companyID)
return c, nil
}
func (s *PostgresStore) CreateContact(input CreateContactInput) Contact {
now := time.Now().UTC()
c := Contact{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Phone: input.Phone,
CompanyID: input.CompanyID,
Title: input.Title,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO contacts (id, workspace_slug, first_name, last_name, email, phone,
company_id, title, notes, avatar_url, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, '', $10, $11)
`, c.ID, c.WorkspaceSlug, c.FirstName, c.LastName, c.Email, c.Phone,
ptrToNullString(c.CompanyID), c.Title, c.Notes, c.CreatedAt, c.UpdatedAt)
return c
}
func (s *PostgresStore) UpdateContact(contactID string, input UpdateContactInput) (Contact, error) {
c, err := s.GetContactByID(contactID)
if err != nil {
return c, err
}
if input.FirstName != nil {
c.FirstName = *input.FirstName
}
if input.LastName != nil {
c.LastName = *input.LastName
}
if input.Email != nil {
c.Email = *input.Email
}
if input.Phone != nil {
c.Phone = *input.Phone
}
if input.CompanyID != nil {
c.CompanyID = input.CompanyID
}
if input.Title != nil {
c.Title = *input.Title
}
if input.Notes != nil {
c.Notes = *input.Notes
}
c.UpdatedAt = time.Now().UTC()
s.db.Exec(`
UPDATE contacts SET first_name = $1, last_name = $2, email = $3, phone = $4,
company_id = $5, title = $6, notes = $7, updated_at = $8
WHERE id = $9
`, c.FirstName, c.LastName, c.Email, c.Phone, ptrToNullString(c.CompanyID),
c.Title, c.Notes, c.UpdatedAt, c.ID)
return c, nil
}
func (s *PostgresStore) DeleteContact(contactID string) error {
_, err := s.db.Exec(`DELETE FROM contacts WHERE id = $1`, contactID)
return err
}
// Company methods
func (s *PostgresStore) ListCompanies(workspaceSlug string) []Company {
rows, err := s.db.Query(`
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
FROM companies
WHERE workspace_slug = $1
ORDER BY name ASC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var companies []Company
for rows.Next() {
var c Company
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
companies = append(companies, c)
}
return companies
}
func (s *PostgresStore) GetCompanyByID(companyID string) (Company, error) {
var c Company
err := s.db.QueryRow(`
SELECT id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at
FROM companies
WHERE id = $1
`, companyID).Scan(&c.ID, &c.WorkspaceSlug, &c.Name, &c.Domain, &c.Website,
&c.Industry, &c.Size, &c.Notes, &c.LogoURL, &c.CreatedAt, &c.UpdatedAt)
return c, err
}
func (s *PostgresStore) CreateCompany(input CreateCompanyInput) Company {
now := time.Now().UTC()
c := Company{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
Domain: input.Domain,
Website: input.Website,
Industry: input.Industry,
Size: input.Size,
Notes: input.Notes,
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO companies (id, workspace_slug, name, domain, website, industry, size, notes, logo_url, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10)
`, c.ID, c.WorkspaceSlug, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.CreatedAt, c.UpdatedAt)
return c
}
func (s *PostgresStore) UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error) {
c, err := s.GetCompanyByID(companyID)
if err != nil {
return c, err
}
if input.Name != nil {
c.Name = *input.Name
}
if input.Domain != nil {
c.Domain = *input.Domain
}
if input.Website != nil {
c.Website = *input.Website
}
if input.Industry != nil {
c.Industry = *input.Industry
}
if input.Size != nil {
c.Size = *input.Size
}
if input.Notes != nil {
c.Notes = *input.Notes
}
c.UpdatedAt = time.Now().UTC()
s.db.Exec(`
UPDATE companies SET name = $1, domain = $2, website = $3, industry = $4, size = $5, notes = $6, updated_at = $7
WHERE id = $8
`, c.Name, c.Domain, c.Website, c.Industry, c.Size, c.Notes, c.UpdatedAt, c.ID)
return c, nil
}
func (s *PostgresStore) DeleteCompany(companyID string) error {
_, err := s.db.Exec(`DELETE FROM companies WHERE id = $1`, companyID)
return err
}
// Contact-Task linking
func (s *PostgresStore) LinkContactToTask(contactID, taskID string) error {
_, err := s.db.Exec(`
INSERT INTO contact_tasks (id, contact_id, task_id, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (contact_id, task_id) DO NOTHING
`, uuid.NewString(), contactID, taskID, time.Now().UTC())
return err
}
func (s *PostgresStore) UnlinkContactFromTask(contactID, taskID string) error {
_, err := s.db.Exec(`DELETE FROM contact_tasks WHERE contact_id = $1 AND task_id = $2`, contactID, taskID)
return err
}
func (s *PostgresStore) ListContactsForTask(taskID string) []Contact {
rows, err := s.db.Query(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
JOIN contact_tasks ct ON c.id = ct.contact_id
LEFT JOIN companies co ON c.company_id = co.id
WHERE ct.task_id = $1
`, taskID)
if err != nil {
return nil
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
var companyID sql.NullString
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
c.CompanyID = nullStringToPtr(companyID)
contacts = append(contacts, c)
}
return contacts
}
// Contact-Event linking
func (s *PostgresStore) LinkContactToEvent(contactID, eventID string) error {
_, err := s.db.Exec(`
INSERT INTO contact_events (id, contact_id, event_id, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (contact_id, event_id) DO NOTHING
`, uuid.NewString(), contactID, eventID, time.Now().UTC())
return err
}
func (s *PostgresStore) ListContactsForEvent(eventID string) []Contact {
rows, err := s.db.Query(`
SELECT c.id, c.workspace_slug, c.first_name, c.last_name, c.email, c.phone,
c.company_id, COALESCE(co.name, ''), c.title, c.notes, c.avatar_url,
c.created_at, c.updated_at
FROM contacts c
JOIN contact_events ce ON c.id = ce.contact_id
LEFT JOIN companies co ON c.company_id = co.id
WHERE ce.event_id = $1
`, eventID)
if err != nil {
return nil
}
defer rows.Close()
var contacts []Contact
for rows.Next() {
var c Contact
var companyID sql.NullString
if err := rows.Scan(&c.ID, &c.WorkspaceSlug, &c.FirstName, &c.LastName, &c.Email,
&c.Phone, &companyID, &c.CompanyName, &c.Title, &c.Notes, &c.AvatarURL,
&c.CreatedAt, &c.UpdatedAt); err != nil {
continue
}
c.CompanyID = nullStringToPtr(companyID)
contacts = append(contacts, c)
}
return contacts
}
// Helper functions
func nullStringToPtr(ns sql.NullString) *string {
if !ns.Valid {
return nil
}
return &ns.String
}
func ptrToNullString(s *string) sql.NullString {
if s == nil {
return sql.NullString{Valid: false}
}
return sql.NullString{String: *s, Valid: true}
}
// Inbox methods
func (s *PostgresStore) ListInboxItems(workspaceSlug string) []InboxItem {
rows, err := s.db.Query(`
SELECT id, workspace_slug, content, source, processed, processed_at,
processed_entity_type, processed_entity_id, created_at
FROM inbox_items
WHERE workspace_slug = $1 AND processed = false
ORDER BY created_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var items []InboxItem
for rows.Next() {
var item InboxItem
var processedAt sql.NullTime
var processedEntityType, processedEntityID sql.NullString
if err := rows.Scan(&item.ID, &item.WorkspaceSlug, &item.Content, &item.Source,
&item.Processed, &processedAt, &processedEntityType, &processedEntityID,
&item.CreatedAt); err != nil {
continue
}
item.ProcessedAt = nullTimeToPtr(processedAt)
item.ProcessedEntityType = nullStringToPtr(processedEntityType)
item.ProcessedEntityID = nullStringToPtr(processedEntityID)
items = append(items, item)
}
return items
}
func (s *PostgresStore) CreateInboxItem(input CreateInboxItemInput) InboxItem {
now := time.Now().UTC()
item := InboxItem{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Content: input.Content,
Source: input.Source,
CreatedAt: now,
}
if item.Source == "" {
item.Source = "manual"
}
s.db.Exec(`
INSERT INTO inbox_items (id, workspace_slug, content, source, processed, created_at)
VALUES ($1, $2, $3, $4, false, $5)
`, item.ID, item.WorkspaceSlug, item.Content, item.Source, item.CreatedAt)
return item
}
func (s *PostgresStore) ProcessInboxItem(itemID string, entityType, entityID string) error {
now := time.Now().UTC()
_, err := s.db.Exec(`
UPDATE inbox_items
SET processed = true, processed_at = $1, processed_entity_type = $2, processed_entity_id = $3
WHERE id = $4
`, now, entityType, entityID, itemID)
return err
}
func (s *PostgresStore) DeleteInboxItem(itemID string) error {
_, err := s.db.Exec(`DELETE FROM inbox_items WHERE id = $1`, itemID)
return err
}
// Time entry methods
func (s *PostgresStore) ListTimeEntries(workspaceSlug string) []TimeEntry {
rows, err := s.db.Query(`
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
FROM time_entries
WHERE workspace_slug = $1
ORDER BY started_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var entries []TimeEntry
for rows.Next() {
var e TimeEntry
var taskID sql.NullString
var endedAt sql.NullTime
if err := rows.Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description,
&e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt); err != nil {
continue
}
e.TaskID = nullStringToPtr(taskID)
e.EndedAt = nullTimeToPtr(endedAt)
entries = append(entries, e)
}
return entries
}
func (s *PostgresStore) CreateTimeEntry(input CreateTimeEntryInput) TimeEntry {
now := time.Now().UTC()
e := TimeEntry{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
TaskID: input.TaskID,
Description: input.Description,
StartedAt: input.StartedAt,
EndedAt: input.EndedAt,
DurationSeconds: 0,
CreatedAt: now,
UpdatedAt: now,
}
if input.EndedAt != nil {
e.DurationSeconds = int(input.EndedAt.Sub(input.StartedAt).Seconds())
}
s.db.Exec(`
INSERT INTO time_entries (id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, e.ID, e.WorkspaceSlug, ptrToNullString(e.TaskID), e.Description, e.StartedAt,
ptrToNullTime(e.EndedAt), e.DurationSeconds, e.CreatedAt, e.UpdatedAt)
return e
}
func (s *PostgresStore) UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error) {
var e TimeEntry
var taskID sql.NullString
var endedAt sql.NullTime
err := s.db.QueryRow(`
SELECT id, workspace_slug, task_id, description, started_at, ended_at, duration_seconds, created_at, updated_at
FROM time_entries WHERE id = $1
`, entryID).Scan(&e.ID, &e.WorkspaceSlug, &taskID, &e.Description, &e.StartedAt, &endedAt, &e.DurationSeconds, &e.CreatedAt, &e.UpdatedAt)
if err != nil {
return e, err
}
e.TaskID = nullStringToPtr(taskID)
e.EndedAt = nullTimeToPtr(endedAt)
if input.Description != nil {
e.Description = *input.Description
}
if input.EndedAt != nil {
e.EndedAt = input.EndedAt
e.DurationSeconds = int(input.EndedAt.Sub(e.StartedAt).Seconds())
}
e.UpdatedAt = time.Now().UTC()
s.db.Exec(`
UPDATE time_entries SET description = $1, ended_at = $2, duration_seconds = $3, updated_at = $4
WHERE id = $5
`, e.Description, ptrToNullTime(e.EndedAt), e.DurationSeconds, e.UpdatedAt, e.ID)
return e, nil
}
func (s *PostgresStore) DeleteTimeEntry(entryID string) error {
_, err := s.db.Exec(`DELETE FROM time_entries WHERE id = $1`, entryID)
return err
}
// Saved view methods
func (s *PostgresStore) ListSavedViews(workspaceSlug, entityType string) []SavedView {
rows, err := s.db.Query(`
SELECT id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at
FROM saved_views
WHERE workspace_slug = $1 AND entity_type = $2
ORDER BY name ASC
`, workspaceSlug, entityType)
if err != nil {
return nil
}
defer rows.Close()
var views []SavedView
for rows.Next() {
var v SavedView
if err := rows.Scan(&v.ID, &v.WorkspaceSlug, &v.Name, &v.EntityType,
&v.FilterJSON, &v.SortJSON, &v.IsDefault, &v.CreatedAt, &v.UpdatedAt); err != nil {
continue
}
views = append(views, v)
}
return views
}
func (s *PostgresStore) CreateSavedView(input CreateSavedViewInput) SavedView {
now := time.Now().UTC()
v := SavedView{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
EntityType: input.EntityType,
FilterJSON: input.FilterJSON,
SortJSON: input.SortJSON,
IsDefault: input.IsDefault,
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO saved_views (id, workspace_slug, name, entity_type, filter_json, sort_json, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, v.ID, v.WorkspaceSlug, v.Name, v.EntityType, v.FilterJSON, v.SortJSON, v.IsDefault, v.CreatedAt, v.UpdatedAt)
return v
}
func (s *PostgresStore) DeleteSavedView(viewID string) error {
_, err := s.db.Exec(`DELETE FROM saved_views WHERE id = $1`, viewID)
return err
}
func nullTimeToPtr(nt sql.NullTime) *time.Time {
if !nt.Valid {
return nil
}
return &nt.Time
}
func ptrToNullTime(t *time.Time) sql.NullTime {
if t == nil {
return sql.NullTime{Valid: false}
}
return sql.NullTime{Time: *t, Valid: true}
}
// Ensure PostgresStore implements Store interface for new methods
var _ Store = (*PostgresStore)(nil)
// Add interface methods to store.go
// These will be added to the Store interface in store.go
@@ -0,0 +1,95 @@
package store
import (
"database/sql"
"time"
)
// Integration represents an external service connection
type Integration struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Provider string `json:"provider"`
Name string `json:"name"`
Config string `json:"config"`
Status string `json:"status"`
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Webhook represents an external webhook endpoint
type Webhook struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Name string `json:"name"`
URL string `json:"url"`
Events string `json:"events"` // JSON array
Active bool `json:"active"`
LastTriggeredAt *time.Time `json:"lastTriggeredAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Notification represents a user notification
type Notification struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
UserEmail string `json:"userEmail"`
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
EntityType *string `json:"entityType,omitempty"`
EntityID *string `json:"entityId,omitempty"`
Read bool `json:"read"`
CreatedAt time.Time `json:"createdAt"`
}
// Presence represents a user's real-time presence
type Presence struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
UserEmail string `json:"userEmail"`
UserName string `json:"userName"`
EntityType *string `json:"entityType,omitempty"`
EntityID *string `json:"entityId,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt"`
CreatedAt time.Time `json:"createdAt"`
}
// CreateIntegrationInput for creating integrations
type CreateIntegrationInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Provider string `json:"provider" binding:"required"`
Name string `json:"name" binding:"required"`
Config string `json:"config"`
Credentials string `json:"credentials" binding:"required"`
}
// CreateWebhookInput for creating webhooks
type CreateWebhookInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Events string `json:"events"` // JSON array
}
// CreateNotificationInput for creating notifications
type CreateNotificationInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
UserEmail string `json:"userEmail" binding:"required"`
Type string `json:"type" binding:"required"`
Title string `json:"title" binding:"required"`
Body string `json:"body"`
EntityType *string `json:"entityType"`
EntityID *string `json:"entityId"`
}
// UpdatePresenceInput for updating presence
type UpdatePresenceInput struct {
WorkspaceSlug string `json:"workspaceSlug" binding:"required"`
UserEmail string `json:"userEmail" binding:"required"`
UserName string `json:"userName" binding:"required"`
EntityType *string `json:"entityType"`
EntityID *string `json:"entityId"`
}
@@ -0,0 +1,269 @@
package store
import (
"database/sql"
"time"
"github.com/google/uuid"
)
// Integration methods
func (s *PostgresStore) ListIntegrations(workspaceSlug string) []Integration {
rows, err := s.db.Query(`
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
FROM integrations
WHERE workspace_slug = $1 AND status = 'active'
ORDER BY created_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var integrations []Integration
for rows.Next() {
var i Integration
var lastSync sql.NullTime
if err := rows.Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt); err != nil {
continue
}
i.LastSyncAt = nullTimeToPtr(lastSync)
integrations = append(integrations, i)
}
return integrations
}
func (s *PostgresStore) GetIntegrationByID(integrationID string) (Integration, error) {
var i Integration
var lastSync sql.NullTime
err := s.db.QueryRow(`
SELECT id, workspace_slug, provider, name, config, status, last_sync_at, created_at, updated_at
FROM integrations WHERE id = $1
`, integrationID).Scan(&i.ID, &i.WorkspaceSlug, &i.Provider, &i.Name, &i.Config,
&i.Status, &lastSync, &i.CreatedAt, &i.UpdatedAt)
if err != nil {
return i, err
}
i.LastSyncAt = nullTimeToPtr(lastSync)
return i, nil
}
func (s *PostgresStore) CreateIntegration(input CreateIntegrationInput) Integration {
now := time.Now().UTC()
i := Integration{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Provider: input.Provider,
Name: input.Name,
Config: input.Config,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
s.db.Exec(`
INSERT INTO integrations (id, workspace_slug, provider, name, config, credentials_ciphertext, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8)
`, i.ID, i.WorkspaceSlug, i.Provider, i.Name, i.Config, input.Credentials, i.CreatedAt, i.UpdatedAt)
return i
}
func (s *PostgresStore) DeleteIntegration(integrationID string) error {
_, err := s.db.Exec(`UPDATE integrations SET status = 'deleted', updated_at = $1 WHERE id = $2`, time.Now().UTC(), integrationID)
return err
}
// Webhook methods
func (s *PostgresStore) ListWebhooks(workspaceSlug string) []Webhook {
rows, err := s.db.Query(`
SELECT id, workspace_slug, name, url, events, active, last_triggered_at, created_at, updated_at
FROM webhooks
WHERE workspace_slug = $1
ORDER BY created_at DESC
`, workspaceSlug)
if err != nil {
return nil
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var lastTriggered sql.NullTime
if err := rows.Scan(&w.ID, &w.WorkspaceSlug, &w.Name, &w.URL, &w.Events,
&w.Active, &lastTriggered, &w.CreatedAt, &w.UpdatedAt); err != nil {
continue
}
w.LastTriggeredAt = nullTimeToPtr(lastTriggered)
webhooks = append(webhooks, w)
}
return webhooks
}
func (s *PostgresStore) CreateWebhook(input CreateWebhookInput) Webhook {
now := time.Now().UTC()
w := Webhook{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Name: input.Name,
URL: input.URL,
Events: input.Events,
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if w.Events == "" {
w.Events = "[]"
}
s.db.Exec(`
INSERT INTO webhooks (id, workspace_slug, name, url, secret, events, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, w.ID, w.WorkspaceSlug, w.Name, w.URL, uuid.NewString(), w.Events, w.Active, w.CreatedAt, w.UpdatedAt)
return w
}
func (s *PostgresStore) DeleteWebhook(webhookID string) error {
_, err := s.db.Exec(`DELETE FROM webhooks WHERE id = $1`, webhookID)
return err
}
// Notification methods
func (s *PostgresStore) ListNotifications(userEmail string, limit int) []Notification {
if limit <= 0 {
limit = 50
}
rows, err := s.db.Query(`
SELECT id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at
FROM notifications
WHERE user_email = $1
ORDER BY created_at DESC
LIMIT $2
`, userEmail, limit)
if err != nil {
return nil
}
defer rows.Close()
var notifications []Notification
for rows.Next() {
var n Notification
var entityType, entityID sql.NullString
if err := rows.Scan(&n.ID, &n.WorkspaceSlug, &n.UserEmail, &n.Type, &n.Title,
&n.Body, &entityType, &entityID, &n.Read, &n.CreatedAt); err != nil {
continue
}
n.EntityType = nullStringToPtr(entityType)
n.EntityID = nullStringToPtr(entityID)
notifications = append(notifications, n)
}
return notifications
}
func (s *PostgresStore) CreateNotification(input CreateNotificationInput) Notification {
n := Notification{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
UserEmail: input.UserEmail,
Type: input.Type,
Title: input.Title,
Body: input.Body,
EntityType: input.EntityType,
EntityID: input.EntityID,
Read: false,
CreatedAt: time.Now().UTC(),
}
s.db.Exec(`
INSERT INTO notifications (id, workspace_slug, user_email, type, title, body, entity_type, entity_id, read, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9)
`, n.ID, n.WorkspaceSlug, n.UserEmail, n.Type, n.Title, n.Body,
ptrToNullString(n.EntityType), ptrToNullString(n.EntityID), n.CreatedAt)
return n
}
func (s *PostgresStore) MarkNotificationRead(notificationID string) error {
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE id = $1`, notificationID)
return err
}
func (s *PostgresStore) MarkAllNotificationsRead(userEmail string) error {
_, err := s.db.Exec(`UPDATE notifications SET read = true WHERE user_email = $1 AND read = false`, userEmail)
return err
}
func (s *PostgresStore) UnreadNotificationCount(userEmail string) int {
var count int
s.db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE user_email = $1 AND read = false`, userEmail).Scan(&count)
return count
}
// Presence methods
func (s *PostgresStore) UpdatePresence(input UpdatePresenceInput) Presence {
now := time.Now().UTC()
// Upsert presence
s.db.Exec(`
INSERT INTO presence (id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (workspace_slug, user_email) DO UPDATE SET
user_name = EXCLUDED.user_name,
entity_type = EXCLUDED.entity_type,
entity_id = EXCLUDED.entity_id,
last_seen_at = EXCLUDED.last_seen_at
`, uuid.NewString(), input.WorkspaceSlug, input.UserEmail, input.UserName,
ptrToNullString(input.EntityType), ptrToNullString(input.EntityID), now, now)
return Presence{
WorkspaceSlug: input.WorkspaceSlug,
UserEmail: input.UserEmail,
UserName: input.UserName,
EntityType: input.EntityType,
EntityID: input.EntityID,
LastSeenAt: now,
CreatedAt: now,
}
}
func (s *PostgresStore) ListPresence(workspaceSlug string, entityType, entityID string) []Presence {
rows, err := s.db.Query(`
SELECT id, workspace_slug, user_email, user_name, entity_type, entity_id, last_seen_at, created_at
FROM presence
WHERE workspace_slug = $1
AND last_seen_at > $2
AND ($3 = '' OR entity_type = $3)
AND ($4 = '' OR entity_id = $4)
ORDER BY last_seen_at DESC
`, workspaceSlug, time.Now().Add(-5*time.Minute), entityType, entityID)
if err != nil {
return nil
}
defer rows.Close()
var presences []Presence
for rows.Next() {
var p Presence
var entityType, entityID sql.NullString
if err := rows.Scan(&p.ID, &p.WorkspaceSlug, &p.UserEmail, &p.UserName,
&entityType, &entityID, &p.LastSeenAt, &p.CreatedAt); err != nil {
continue
}
p.EntityType = nullStringToPtr(entityType)
p.EntityID = nullStringToPtr(entityID)
presences = append(presences, p)
}
return presences
}
func (s *PostgresStore) ClearPresence(workspaceSlug, userEmail string) error {
_, err := s.db.Exec(`DELETE FROM presence WHERE workspace_slug = $1 AND user_email = $2`, workspaceSlug, userEmail)
return err
}
+453
View File
@@ -0,0 +1,453 @@
package store
import (
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
type Mailbox struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
Label string `json:"label"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
IMAPHost string `json:"imapHost"`
IMAPPort int `json:"imapPort"`
IMAPUsername string `json:"imapUsername"`
IMAPUseTLS bool `json:"imapUseTls"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPUseTLS bool `json:"smtpUseTls"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
SyncStatus string `json:"syncStatus"`
SyncError string `json:"syncError,omitempty"`
}
type MailAddress struct {
Name string `json:"name"`
Email string `json:"email"`
}
type MailMessage struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
MailboxID string `json:"mailboxId"`
RemoteUID int64 `json:"remoteUid"`
MessageID string `json:"messageId"`
Folder string `json:"folder"`
From MailAddress `json:"from"`
To []MailAddress `json:"to"`
Cc []MailAddress `json:"cc"`
Subject string `json:"subject"`
Snippet string `json:"snippet"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody"`
ReceivedAt time.Time `json:"receivedAt"`
IsRead bool `json:"isRead"`
LinkedTaskID *string `json:"linkedTaskId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type OutgoingMail struct {
ID string `json:"id"`
WorkspaceSlug string `json:"workspaceSlug"`
MailboxID string `json:"mailboxId"`
To []MailAddress `json:"to"`
Cc []MailAddress `json:"cc"`
Bcc []MailAddress `json:"bcc"`
Subject string `json:"subject"`
TextBody string `json:"textBody"`
HTMLBody string `json:"htmlBody"`
Status string `json:"status"`
ScheduledFor *time.Time `json:"scheduledFor,omitempty"`
SentAt *time.Time `json:"sentAt,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type MailboxConnection struct {
Mailbox
IMAPPasswordCiphertext string
SMTPPasswordCiphertext string
}
type CreateMailboxRecordInput struct {
WorkspaceSlug string
Label string
Email string
DisplayName string
IMAPHost string
IMAPPort int
IMAPUsername string
IMAPPasswordCiphertext string
IMAPUseTLS bool
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPasswordCiphertext string
SMTPUseTLS bool
}
type UpdateMailboxSyncStatusInput struct {
SyncStatus string
SyncError *string
LastSyncedAt *time.Time
}
type InboundMailMessage struct {
WorkspaceSlug string
MailboxID string
RemoteUID int64
MessageID string
Folder string
From MailAddress
To []MailAddress
Cc []MailAddress
Subject string
Snippet string
TextBody string
HTMLBody string
ReceivedAt time.Time
IsRead bool
}
type CreateOutgoingMailInput struct {
WorkspaceSlug string
MailboxID string
To []MailAddress
Cc []MailAddress
Bcc []MailAddress
Subject string
TextBody string
HTMLBody string
Status string
ScheduledFor *time.Time
}
type UpdateOutgoingMailStatusInput struct {
Status string
SentAt *time.Time
Error *string
}
func (s *State) ListAllMailboxes() []Mailbox {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]Mailbox(nil), s.Mailboxes...)
}
func (s *State) ListMailboxes(workspaceSlug string) []Mailbox {
s.mu.RLock()
defer s.mu.RUnlock()
return filterByWorkspace(s.Mailboxes, workspaceSlug)
}
func (s *State) GetMailboxByID(mailboxID string) (Mailbox, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, mailbox := range s.Mailboxes {
if mailbox.ID == mailboxID {
return mailbox, nil
}
}
return Mailbox{}, errors.New("mailbox not found")
}
func (s *State) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
s.mu.RLock()
defer s.mu.RUnlock()
connection, ok := s.MailboxAuth[mailboxID]
if !ok {
return MailboxConnection{}, errors.New("mailbox connection not found")
}
return connection, nil
}
func (s *State) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
mailbox := Mailbox{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
Label: input.Label,
Email: input.Email,
DisplayName: input.DisplayName,
IMAPHost: input.IMAPHost,
IMAPPort: input.IMAPPort,
IMAPUsername: input.IMAPUsername,
IMAPUseTLS: input.IMAPUseTLS,
SMTPHost: input.SMTPHost,
SMTPPort: input.SMTPPort,
SMTPUsername: input.SMTPUsername,
SMTPUseTLS: input.SMTPUseTLS,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "idle",
}
s.Mailboxes = append([]Mailbox{mailbox}, s.Mailboxes...)
s.MailboxAuth[mailbox.ID] = MailboxConnection{
Mailbox: mailbox,
IMAPPasswordCiphertext: input.IMAPPasswordCiphertext,
SMTPPasswordCiphertext: input.SMTPPasswordCiphertext,
}
s.appendActivityLocked(input.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", input.Email))
return mailbox, nil
}
func (s *State) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
s.mu.Lock()
defer s.mu.Unlock()
for index, mailbox := range s.Mailboxes {
if mailbox.ID != mailboxID {
continue
}
if input.SyncStatus != "" {
mailbox.SyncStatus = input.SyncStatus
}
if input.SyncError != nil {
mailbox.SyncError = *input.SyncError
}
if input.LastSyncedAt != nil {
mailbox.LastSyncedAt = input.LastSyncedAt
}
mailbox.UpdatedAt = time.Now().UTC()
s.Mailboxes[index] = mailbox
if connection, ok := s.MailboxAuth[mailboxID]; ok {
connection.Mailbox = mailbox
s.MailboxAuth[mailboxID] = connection
}
return mailbox, nil
}
return Mailbox{}, errors.New("mailbox not found")
}
func (s *State) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
s.mu.RLock()
defer s.mu.RUnlock()
items := filterByWorkspace(s.MailMessages, workspaceSlug)
if mailboxID == "" {
return items
}
filtered := make([]MailMessage, 0, len(items))
for _, item := range items {
if item.MailboxID == mailboxID {
filtered = append(filtered, item)
}
}
return filtered
}
func (s *State) GetMailMessageByID(messageID string) (MailMessage, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, message := range s.MailMessages {
if message.ID == messageID {
return message, nil
}
}
return MailMessage{}, errors.New("mail message not found")
}
func (s *State) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
for _, input := range messages {
found := false
for index, message := range s.MailMessages {
if message.MailboxID == mailboxID && message.Folder == input.Folder && message.RemoteUID == input.RemoteUID {
linkedTaskID := message.LinkedTaskID
s.MailMessages[index] = MailMessage{
ID: message.ID,
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: mailboxID,
RemoteUID: input.RemoteUID,
MessageID: input.MessageID,
Folder: input.Folder,
From: input.From,
To: input.To,
Cc: input.Cc,
Subject: input.Subject,
Snippet: input.Snippet,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
ReceivedAt: input.ReceivedAt,
IsRead: input.IsRead,
LinkedTaskID: linkedTaskID,
CreatedAt: message.CreatedAt,
UpdatedAt: now,
}
found = true
break
}
}
if found {
continue
}
s.MailMessages = append([]MailMessage{{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: mailboxID,
RemoteUID: input.RemoteUID,
MessageID: input.MessageID,
Folder: input.Folder,
From: input.From,
To: input.To,
Cc: input.Cc,
Subject: input.Subject,
Snippet: input.Snippet,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
ReceivedAt: input.ReceivedAt,
IsRead: input.IsRead,
CreatedAt: now,
UpdatedAt: now,
}}, s.MailMessages...)
}
return nil
}
func (s *State) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
for index, message := range s.MailMessages {
if message.ID != messageID {
continue
}
message.LinkedTaskID = &taskID
message.UpdatedAt = time.Now().UTC()
s.MailMessages[index] = message
return message, nil
}
return MailMessage{}, errors.New("mail message not found")
}
func (s *State) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
s.mu.RLock()
defer s.mu.RUnlock()
items := filterByWorkspace(s.OutgoingMails, workspaceSlug)
if mailboxID == "" {
return items
}
filtered := make([]OutgoingMail, 0, len(items))
for _, item := range items {
if item.MailboxID == mailboxID {
filtered = append(filtered, item)
}
}
return filtered
}
func (s *State) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]OutgoingMail, 0, limit)
for _, item := range s.OutgoingMails {
if item.Status != "queued" && item.Status != "scheduled" {
continue
}
if item.ScheduledFor != nil && item.ScheduledFor.After(now) {
continue
}
items = append(items, item)
if limit > 0 && len(items) >= limit {
break
}
}
return items
}
func (s *State) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, item := range s.OutgoingMails {
if item.ID == outgoingMailID {
return item, nil
}
}
return OutgoingMail{}, errors.New("outgoing mail not found")
}
func (s *State) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
item := OutgoingMail{
ID: uuid.NewString(),
WorkspaceSlug: input.WorkspaceSlug,
MailboxID: input.MailboxID,
To: input.To,
Cc: input.Cc,
Bcc: input.Bcc,
Subject: input.Subject,
TextBody: input.TextBody,
HTMLBody: input.HTMLBody,
Status: input.Status,
ScheduledFor: input.ScheduledFor,
CreatedAt: now,
UpdatedAt: now,
}
s.OutgoingMails = append([]OutgoingMail{item}, s.OutgoingMails...)
s.appendActivityLocked(input.WorkspaceSlug, "Outgoing mail queued", input.Subject)
return item, nil
}
func (s *State) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
s.mu.Lock()
defer s.mu.Unlock()
for index, item := range s.OutgoingMails {
if item.ID != outgoingMailID {
continue
}
if input.Status != "" {
item.Status = input.Status
}
if input.SentAt != nil {
item.SentAt = input.SentAt
}
if input.Error != nil {
item.Error = *input.Error
}
item.UpdatedAt = time.Now().UTC()
s.OutgoingMails[index] = item
return item, nil
}
return OutgoingMail{}, errors.New("outgoing mail not found")
}
func (item Mailbox) GetWorkspaceSlug() string { return item.WorkspaceSlug }
func (item MailMessage) GetWorkspaceSlug() string { return item.WorkspaceSlug }
func (item OutgoingMail) GetWorkspaceSlug() string { return item.WorkspaceSlug }
@@ -0,0 +1,550 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
func (s *PostgresStore) ListAllMailboxes() []Mailbox {
rows, err := s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
FROM mailboxes
ORDER BY updated_at DESC
`)
if err != nil {
panic(err)
}
defer rows.Close()
return scanMailboxes(rows)
}
func (s *PostgresStore) ListMailboxes(workspaceSlug string) []Mailbox {
rows, err := s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
FROM mailboxes
WHERE workspace_slug = $1
ORDER BY updated_at DESC
`, workspaceSlug)
if err != nil {
panic(err)
}
defer rows.Close()
return scanMailboxes(rows)
}
func (s *PostgresStore) GetMailboxByID(mailboxID string) (Mailbox, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
FROM mailboxes
WHERE id = $1
`, mailboxID)
return scanMailbox(row)
}
func (s *PostgresStore) GetMailboxConnection(mailboxID string) (MailboxConnection, error) {
var (
connection MailboxConnection
lastSynced sql.NullTime
syncError sql.NullString
)
err := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error,
imap_password_ciphertext, smtp_password_ciphertext
FROM mailboxes
WHERE id = $1
`, mailboxID).Scan(
&connection.ID,
&connection.WorkspaceSlug,
&connection.Label,
&connection.Email,
&connection.DisplayName,
&connection.IMAPHost,
&connection.IMAPPort,
&connection.IMAPUsername,
&connection.IMAPUseTLS,
&connection.SMTPHost,
&connection.SMTPPort,
&connection.SMTPUsername,
&connection.SMTPUseTLS,
&connection.CreatedAt,
&connection.UpdatedAt,
&lastSynced,
&connection.SyncStatus,
&syncError,
&connection.IMAPPasswordCiphertext,
&connection.SMTPPasswordCiphertext,
)
if err != nil {
return MailboxConnection{}, err
}
connection.LastSyncedAt = timePtr(lastSynced)
connection.SyncError = syncError.String
return connection, nil
}
func (s *PostgresStore) CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error) {
now := time.Now().UTC()
row := s.db.QueryRowContext(context.Background(), `
INSERT INTO mailboxes (
id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_password_ciphertext, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_password_ciphertext, smtp_use_tls, sync_status, sync_error, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'idle', '', $16, $16)
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
`,
uuid.NewString(),
input.WorkspaceSlug,
defaultString(input.Label, input.Email),
input.Email,
input.DisplayName,
input.IMAPHost,
input.IMAPPort,
input.IMAPUsername,
input.IMAPPasswordCiphertext,
input.IMAPUseTLS,
input.SMTPHost,
input.SMTPPort,
input.SMTPUsername,
input.SMTPPasswordCiphertext,
input.SMTPUseTLS,
now,
)
mailbox, err := scanMailbox(row)
if err != nil {
return Mailbox{}, err
}
if err := appendActivity(context.Background(), s.queries, mailbox.WorkspaceSlug, "Mailbox connected", fmt.Sprintf("%s is ready for sync.", mailbox.Email)); err != nil {
return Mailbox{}, err
}
return mailbox, nil
}
func (s *PostgresStore) UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error) {
row := s.db.QueryRowContext(context.Background(), `
UPDATE mailboxes
SET sync_status = COALESCE(NULLIF($2, ''), sync_status),
sync_error = COALESCE($3, sync_error),
last_synced_at = COALESCE($4, last_synced_at),
updated_at = $5
WHERE id = $1
RETURNING id, workspace_slug, label, email, display_name, imap_host, imap_port, imap_username, imap_use_tls,
smtp_host, smtp_port, smtp_username, smtp_use_tls, created_at, updated_at, last_synced_at, sync_status, sync_error
`, mailboxID, input.SyncStatus, nullableString(input.SyncError), nullableTime(input.LastSyncedAt), time.Now().UTC())
return scanMailbox(row)
}
func (s *PostgresStore) ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage {
var (
rows *sql.Rows
err error
)
if mailboxID == "" {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
FROM mail_messages
WHERE workspace_slug = $1
ORDER BY received_at DESC
LIMIT 120
`, workspaceSlug)
} else {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
FROM mail_messages
WHERE workspace_slug = $1 AND mailbox_id = $2
ORDER BY received_at DESC
LIMIT 120
`, workspaceSlug, mailboxID)
}
if err != nil {
panic(err)
}
defer rows.Close()
return scanMailMessages(rows)
}
func (s *PostgresStore) GetMailMessageByID(messageID string) (MailMessage, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
FROM mail_messages
WHERE id = $1
`, messageID)
return scanMailMessage(row)
}
func (s *PostgresStore) UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error {
ctx := context.Background()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
now := time.Now().UTC()
for _, message := range messages {
_, err := tx.ExecContext(ctx, `
INSERT INTO mail_messages (
id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, $16, $16)
ON CONFLICT (mailbox_id, folder, remote_uid)
DO UPDATE SET
message_id = EXCLUDED.message_id,
from_address = EXCLUDED.from_address,
to_recipients = EXCLUDED.to_recipients,
cc_recipients = EXCLUDED.cc_recipients,
subject = EXCLUDED.subject,
snippet = EXCLUDED.snippet,
text_body = EXCLUDED.text_body,
html_body = EXCLUDED.html_body,
received_at = EXCLUDED.received_at,
is_read = EXCLUDED.is_read,
updated_at = EXCLUDED.updated_at
`,
uuid.NewString(),
message.WorkspaceSlug,
mailboxID,
message.RemoteUID,
message.MessageID,
defaultString(message.Folder, "INBOX"),
mustJSON(message.From),
mustJSON(message.To),
mustJSON(message.Cc),
message.Subject,
message.Snippet,
message.TextBody,
message.HTMLBody,
message.ReceivedAt,
message.IsRead,
now,
)
if err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (s *PostgresStore) LinkMailMessageTask(messageID string, taskID string) (MailMessage, error) {
row := s.db.QueryRowContext(context.Background(), `
UPDATE mail_messages
SET linked_task_id = $2, updated_at = $3
WHERE id = $1
RETURNING id, workspace_slug, mailbox_id, remote_uid, message_id, folder, from_address, to_recipients, cc_recipients,
subject, snippet, text_body, html_body, received_at, is_read, linked_task_id, created_at, updated_at
`, messageID, taskID, time.Now().UTC())
return scanMailMessage(row)
}
func (s *PostgresStore) ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail {
var (
rows *sql.Rows
err error
)
if mailboxID == "" {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE workspace_slug = $1
ORDER BY created_at DESC
LIMIT 120
`, workspaceSlug)
} else {
rows, err = s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE workspace_slug = $1 AND mailbox_id = $2
ORDER BY created_at DESC
LIMIT 120
`, workspaceSlug, mailboxID)
}
if err != nil {
panic(err)
}
defer rows.Close()
return scanOutgoingMails(rows)
}
func (s *PostgresStore) ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail {
rows, err := s.db.QueryContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE status IN ('queued', 'scheduled')
AND (scheduled_for IS NULL OR scheduled_for <= $1)
ORDER BY created_at ASC
LIMIT $2
`, now, limit)
if err != nil {
panic(err)
}
defer rows.Close()
return scanOutgoingMails(rows)
}
func (s *PostgresStore) GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error) {
row := s.db.QueryRowContext(context.Background(), `
SELECT id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
FROM outgoing_mails
WHERE id = $1
`, outgoingMailID)
return scanOutgoingMail(row)
}
func (s *PostgresStore) CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error) {
now := time.Now().UTC()
row := s.db.QueryRowContext(context.Background(), `
INSERT INTO outgoing_mails (
id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULL, '', $12, $12)
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
`,
uuid.NewString(),
input.WorkspaceSlug,
input.MailboxID,
mustJSON(input.To),
mustJSON(input.Cc),
mustJSON(input.Bcc),
input.Subject,
input.TextBody,
input.HTMLBody,
input.Status,
nullableTime(input.ScheduledFor),
now,
)
return scanOutgoingMail(row)
}
func (s *PostgresStore) UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error) {
row := s.db.QueryRowContext(context.Background(), `
UPDATE outgoing_mails
SET status = COALESCE(NULLIF($2, ''), status),
sent_at = COALESCE($3, sent_at),
error_message = COALESCE($4, error_message),
updated_at = $5
WHERE id = $1
RETURNING id, workspace_slug, mailbox_id, to_recipients, cc_recipients, bcc_recipients, subject, text_body, html_body,
status, scheduled_for, sent_at, error_message, created_at, updated_at
`, outgoingMailID, input.Status, nullableTime(input.SentAt), nullableString(input.Error), time.Now().UTC())
return scanOutgoingMail(row)
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanMailboxes(rows *sql.Rows) []Mailbox {
items := make([]Mailbox, 0)
for rows.Next() {
item, err := scanMailbox(rows)
if err != nil {
panic(err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
panic(err)
}
return items
}
func scanMailbox(row rowScanner) (Mailbox, error) {
var (
item Mailbox
lastSynced sql.NullTime
syncError sql.NullString
)
err := row.Scan(
&item.ID,
&item.WorkspaceSlug,
&item.Label,
&item.Email,
&item.DisplayName,
&item.IMAPHost,
&item.IMAPPort,
&item.IMAPUsername,
&item.IMAPUseTLS,
&item.SMTPHost,
&item.SMTPPort,
&item.SMTPUsername,
&item.SMTPUseTLS,
&item.CreatedAt,
&item.UpdatedAt,
&lastSynced,
&item.SyncStatus,
&syncError,
)
if err != nil {
return Mailbox{}, err
}
item.LastSyncedAt = timePtr(lastSynced)
item.SyncError = syncError.String
return item, nil
}
func scanMailMessages(rows *sql.Rows) []MailMessage {
items := make([]MailMessage, 0)
for rows.Next() {
item, err := scanMailMessage(rows)
if err != nil {
panic(err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
panic(err)
}
return items
}
func scanMailMessage(row rowScanner) (MailMessage, error) {
var (
item MailMessage
fromJSON []byte
toJSON []byte
ccJSON []byte
linkedTaskID sql.NullString
)
err := row.Scan(
&item.ID,
&item.WorkspaceSlug,
&item.MailboxID,
&item.RemoteUID,
&item.MessageID,
&item.Folder,
&fromJSON,
&toJSON,
&ccJSON,
&item.Subject,
&item.Snippet,
&item.TextBody,
&item.HTMLBody,
&item.ReceivedAt,
&item.IsRead,
&linkedTaskID,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
return MailMessage{}, err
}
if err := decodeJSONValue(fromJSON, &item.From); err != nil {
return MailMessage{}, err
}
to, err := decodeJSONSlice[MailAddress](toJSON)
if err != nil {
return MailMessage{}, err
}
cc, err := decodeJSONSlice[MailAddress](ccJSON)
if err != nil {
return MailMessage{}, err
}
item.To = to
item.Cc = cc
item.LinkedTaskID = stringPtr(linkedTaskID)
return item, nil
}
func scanOutgoingMails(rows *sql.Rows) []OutgoingMail {
items := make([]OutgoingMail, 0)
for rows.Next() {
item, err := scanOutgoingMail(rows)
if err != nil {
panic(err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
panic(err)
}
return items
}
func scanOutgoingMail(row rowScanner) (OutgoingMail, error) {
var (
item OutgoingMail
toJSON []byte
ccJSON []byte
bccJSON []byte
scheduledFor sql.NullTime
sentAt sql.NullTime
errorMessage sql.NullString
)
err := row.Scan(
&item.ID,
&item.WorkspaceSlug,
&item.MailboxID,
&toJSON,
&ccJSON,
&bccJSON,
&item.Subject,
&item.TextBody,
&item.HTMLBody,
&item.Status,
&scheduledFor,
&sentAt,
&errorMessage,
&item.CreatedAt,
&item.UpdatedAt,
)
if err != nil {
return OutgoingMail{}, err
}
to, err := decodeJSONSlice[MailAddress](toJSON)
if err != nil {
return OutgoingMail{}, err
}
cc, err := decodeJSONSlice[MailAddress](ccJSON)
if err != nil {
return OutgoingMail{}, err
}
bcc, err := decodeJSONSlice[MailAddress](bccJSON)
if err != nil {
return OutgoingMail{}, err
}
item.To = to
item.Cc = cc
item.Bcc = bcc
item.ScheduledFor = timePtr(scheduledFor)
item.SentAt = timePtr(sentAt)
item.Error = errorMessage.String
return item, nil
}
func decodeJSONValue(raw []byte, target any) error {
if len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, target)
}
@@ -0,0 +1,133 @@
package store
import (
"time"
"github.com/google/uuid"
)
// CreateNotificationForTaskAssignment creates a notification when a task is assigned
func (s *PostgresStore) CreateNotificationForTaskAssignment(workspaceSlug, assigneeEmail, taskTitle, taskID string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: assigneeEmail,
Type: "task_assigned",
Title: "Task assigned to you",
Body: "You have been assigned to: " + taskTitle,
EntityType: strPtr("task"),
EntityID: strPtr(taskID),
})
}
// CreateNotificationForMention creates a notification when a user is mentioned
func (s *PostgresStore) CreateNotificationForMention(workspaceSlug, mentionedEmail, mentionerName, entityType, entityID, context string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: mentionedEmail,
Type: "mention",
Title: mentionerName + " mentioned you",
Body: context,
EntityType: strPtr(entityType),
EntityID: strPtr(entityID),
})
}
// CreateNotificationForComment creates a notification for a new comment on an entity
func (s *PostgresStore) CreateNotificationForComment(workspaceSlug, ownerEmail, commenterName, entityType, entityID, entityTitle string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: ownerEmail,
Type: "comment",
Title: commenterName + " commented on " + entityTitle,
Body: "New comment on " + entityType,
EntityType: strPtr(entityType),
EntityID: strPtr(entityID),
})
}
// CreateNotificationForTaskCompletion creates a notification when a task is completed
func (s *PostgresStore) CreateNotificationForTaskCompletion(workspaceSlug, assignerEmail, taskTitle, taskID string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: assignerEmail,
Type: "task_completed",
Title: "Task completed: " + taskTitle,
Body: "A task you assigned has been completed",
EntityType: strPtr("task"),
EntityID: strPtr(taskID),
})
}
// CreateNotificationForEventReminder creates a notification for an upcoming event
func (s *PostgresStore) CreateNotificationForEventReminder(workspaceSlug, userEmail, eventTitle, eventID string) {
s.CreateNotification(CreateNotificationInput{
WorkspaceSlug: workspaceSlug,
UserEmail: userEmail,
Type: "event_reminder",
Title: "Upcoming event: " + eventTitle,
Body: "Your event is starting soon",
EntityType: strPtr("event"),
EntityID: strPtr(eventID),
})
}
// TriggerWebhooks triggers all webhooks for a given event type
func (s *PostgresStore) TriggerWebhooks(workspaceSlug, eventType string, payload map[string]interface{}) {
// Get all active webhooks for this workspace
rows, err := s.db.Query(`
SELECT id, url, secret, events
FROM webhooks
WHERE workspace_slug = $1 AND active = true
`, workspaceSlug)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var id, url, secret, eventsJSON string
if err := rows.Scan(&id, &url, &secret, &eventsJSON); err != nil {
continue
}
// Check if this webhook subscribes to the event
// Simple string contains check for the event type in the JSON array
if !containsEvent(eventsJSON, eventType) {
continue
}
// Update last triggered timestamp
s.db.Exec(`UPDATE webhooks SET last_triggered_at = $1 WHERE id = $2`, time.Now().UTC(), id)
// Webhook delivery would happen here in a goroutine
// For now, we just mark it as triggered
go deliverWebhook(url, secret, eventType, payload)
}
}
func strPtr(s string) *string {
return &s
}
func containsEvent(eventsJSON, eventType string) bool {
// Simple check - in production would parse JSON properly
return len(eventsJSON) > 2 &&
(eventsJSON == "[]" ||
eventsJSON == "[\""+eventType+"\"]" ||
containsSubstring(eventsJSON, "\""+eventType+"\""))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func deliverWebhook(url, secret, eventType string, payload map[string]interface{}) {
// Webhook delivery implementation
// In production, this would make an HTTP POST request
// with proper signature using the secret
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestMigrationsDirUsesEnvOverrideWhenValid(t *testing.T) {
tempDir := t.TempDir()
customDir := filepath.Join(tempDir, "migrations")
if err := os.MkdirAll(customDir, 0o755); err != nil {
t.Fatalf("create temp migrations dir: %v", err)
}
t.Setenv("DB_MIGRATIONS_DIR", customDir)
if got := migrationsDir(); got != customDir {
t.Fatalf("migrationsDir() = %q, want %q", got, customDir)
}
}
func TestMigrationsDirFallsBackWhenEnvOverrideIsInvalid(t *testing.T) {
t.Setenv("DB_MIGRATIONS_DIR", filepath.Join(t.TempDir(), "missing"))
got := migrationsDir()
if got == "" {
t.Fatal("migrationsDir() should never return empty path")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
package store
import "testing"
func TestStateUpdateMemberRejectsLastActiveOwnerChange(t *testing.T) {
state := NewSeededState("test")
owner := findMemberByRole(t, state, "owner")
nextRole := "member"
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole}); err == nil {
t.Fatalf("expected last active owner demotion to fail")
}
nextStatus := "removed"
if _, err := state.UpdateMember(owner.ID, UpdateMemberInput{Status: &nextStatus}); err == nil {
t.Fatalf("expected last active owner deactivation to fail")
}
}
func TestStateUpdateMemberAllowsOwnerChangeWhenAnotherOwnerExists(t *testing.T) {
state := NewSeededState("test")
owner := findMemberByRole(t, state, "owner")
state.Members = append(state.Members, Member{
ID: "member-owner-2",
WorkspaceSlug: owner.WorkspaceSlug,
Name: "Backup Owner",
Email: "backup-owner@productier.app",
Role: "owner",
Status: "active",
})
nextRole := "admin"
updated, err := state.UpdateMember(owner.ID, UpdateMemberInput{Role: &nextRole})
if err != nil {
t.Fatalf("expected owner demotion to succeed with another active owner: %v", err)
}
if updated.Role != "admin" {
t.Fatalf("expected updated role admin, got %s", updated.Role)
}
}
func TestStateRevokeInviteRules(t *testing.T) {
state := NewSeededState("test")
if len(state.Invites) == 0 {
t.Fatalf("seed state has no invites")
}
invite := state.Invites[0]
if err := state.RevokeInvite(invite.ID); err != nil {
t.Fatalf("expected pending invite revoke to succeed: %v", err)
}
if _, err := state.GetInviteByID(invite.ID); err == nil {
t.Fatalf("expected revoked invite to be absent")
}
}
func TestStateRevokeInviteRejectedWhenAccepted(t *testing.T) {
state := NewSeededState("test")
if len(state.Invites) == 0 {
t.Fatalf("seed state has no invites")
}
invite := state.Invites[0]
if _, err := state.AcceptInvite(invite.Token, AcceptInviteInput{Name: "Taylor", Email: invite.Email}); err != nil {
t.Fatalf("accept invite setup failed: %v", err)
}
if err := state.RevokeInvite(invite.ID); err == nil {
t.Fatalf("expected revoke of accepted invite to fail")
}
}
func findMemberByRole(t *testing.T, state *State, role string) Member {
t.Helper()
for _, member := range state.Members {
if member.Role == role && member.Status == "active" {
return member
}
}
t.Fatalf("no active member with role %s", role)
return Member{}
}
+103
View File
@@ -0,0 +1,103 @@
package store
import "time"
type Store interface {
ListWorkspaces() []Workspace
ListMembers(workspaceSlug string) []Member
GetMemberByID(memberID string) (Member, error)
UpdateMember(memberID string, input UpdateMemberInput) (Member, error)
ListInvites(workspaceSlug string) []Invite
GetInviteByID(inviteID string) (Invite, error)
GetInviteByToken(token string) (Invite, error)
CreateInvite(input CreateInviteInput) Invite
RevokeInvite(inviteID string) error
AcceptInvite(token string, input AcceptInviteInput) (Invite, error)
ListActivities(workspaceSlug string) []ActivityEntry
ListBoardGroups(workspaceSlug string) []BoardGroup
GetBoardGroupByID(groupID string) (BoardGroup, error)
CreateBoardGroup(input CreateBoardGroupInput) BoardGroup
UpdateBoardGroup(groupID string, input UpdateBoardGroupInput) (BoardGroup, error)
ListLabels(workspaceSlug string) []Label
CreateLabel(input CreateLabelInput) Label
ListTasks(workspaceSlug string) []Task
GetTaskByID(taskID string) (Task, error)
CreateTask(input CreateTaskInput) Task
UpdateTask(taskID string, input UpdateTaskInput) (Task, error)
ListEvents(workspaceSlug string) []CalendarEvent
GetEventByID(eventID string) (CalendarEvent, error)
CreateEvent(input CreateEventInput) CalendarEvent
UpdateEvent(eventID string, input UpdateEventInput) (CalendarEvent, error)
ListNotes(workspaceSlug string) []Note
GetNoteByID(noteID string) (Note, error)
CreateNote(input CreateNoteInput) Note
UpdateNote(noteID string, input UpdateNoteInput) (Note, error)
ListFocusSessions(workspaceSlug string) []FocusSession
GetFocusSessionByID(sessionID string) (FocusSession, error)
CreateFocusSession(input CreateFocusSessionInput) FocusSession
UpdateFocusSession(sessionID string, input UpdateFocusSessionInput) (FocusSession, error)
ListAllMailboxes() []Mailbox
ListMailboxes(workspaceSlug string) []Mailbox
GetMailboxByID(mailboxID string) (Mailbox, error)
GetMailboxConnection(mailboxID string) (MailboxConnection, error)
CreateMailbox(input CreateMailboxRecordInput) (Mailbox, error)
UpdateMailboxSyncStatus(mailboxID string, input UpdateMailboxSyncStatusInput) (Mailbox, error)
ListMailMessages(workspaceSlug string, mailboxID string) []MailMessage
GetMailMessageByID(messageID string) (MailMessage, error)
UpsertMailMessages(mailboxID string, messages []InboundMailMessage) error
LinkMailMessageTask(messageID string, taskID string) (MailMessage, error)
ListOutgoingMails(workspaceSlug string, mailboxID string) []OutgoingMail
ListDueOutgoingMails(now time.Time, limit int) []OutgoingMail
GetOutgoingMailByID(outgoingMailID string) (OutgoingMail, error)
CreateOutgoingMail(input CreateOutgoingMailInput) (OutgoingMail, error)
UpdateOutgoingMailStatus(outgoingMailID string, input UpdateOutgoingMailStatusInput) (OutgoingMail, error)
// CRM
ListContacts(workspaceSlug string) []Contact
GetContactByID(contactID string) (Contact, error)
CreateContact(input CreateContactInput) Contact
UpdateContact(contactID string, input UpdateContactInput) (Contact, error)
DeleteContact(contactID string) error
ListCompanies(workspaceSlug string) []Company
GetCompanyByID(companyID string) (Company, error)
CreateCompany(input CreateCompanyInput) Company
UpdateCompany(companyID string, input UpdateCompanyInput) (Company, error)
DeleteCompany(companyID string) error
LinkContactToTask(contactID, taskID string) error
UnlinkContactFromTask(contactID, taskID string) error
ListContactsForTask(taskID string) []Contact
LinkContactToEvent(contactID, eventID string) error
ListContactsForEvent(eventID string) []Contact
// Inbox
ListInboxItems(workspaceSlug string) []InboxItem
CreateInboxItem(input CreateInboxItemInput) InboxItem
ProcessInboxItem(itemID string, entityType, entityID string) error
DeleteInboxItem(itemID string) error
// Time tracking
ListTimeEntries(workspaceSlug string) []TimeEntry
CreateTimeEntry(input CreateTimeEntryInput) TimeEntry
UpdateTimeEntry(entryID string, input UpdateTimeEntryInput) (TimeEntry, error)
DeleteTimeEntry(entryID string) error
// Saved views
ListSavedViews(workspaceSlug, entityType string) []SavedView
CreateSavedView(input CreateSavedViewInput) SavedView
DeleteSavedView(viewID string) error
// Integrations
ListIntegrations(workspaceSlug string) []Integration
GetIntegrationByID(integrationID string) (Integration, error)
CreateIntegration(input CreateIntegrationInput) Integration
DeleteIntegration(integrationID string) error
// Webhooks
ListWebhooks(workspaceSlug string) []Webhook
CreateWebhook(input CreateWebhookInput) Webhook
DeleteWebhook(webhookID string) error
// Notifications
ListNotifications(userEmail string, limit int) []Notification
CreateNotification(input CreateNotificationInput) Notification
MarkNotificationRead(notificationID string) error
MarkAllNotificationsRead(userEmail string) error
UnreadNotificationCount(userEmail string) int
// Presence
UpdatePresence(input UpdatePresenceInput) Presence
ListPresence(workspaceSlug string, entityType, entityID string) []Presence
ClearPresence(workspaceSlug, userEmail string) error
}
BIN
View File
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
# Productier Backend Remote Deployment Environment
# Copy to .env for remote/self-hosted deployment
# Application Environment
APP_ENV=production
API_PORT=8080
API_SHUTDOWN_TIMEOUT=15s
# Database (REQUIRED - update with your PostgreSQL connection)
DATABASE_URL=postgres://productier:your-secure-password@your-postgres-host:5432/productier?sslmode=require
# Auth Service (REQUIRED - URL where auth service is accessible)
AUTH_SERVICE_URL=http://your-auth-host:3001
# Secrets (REQUIRED - generate strong random secrets)
BETTER_AUTH_SECRET=generate-a-32-plus-char-random-secret-here
MAIL_ENCRYPTION_KEY=generate-another-32-plus-char-random-secret-here
# CORS (REQUIRED - comma-separated allowed origins)
CORS_ALLOW_ORIGINS=https://your-frontend-domain.com,https://your-api-domain.com
# File Storage
FILE_STORAGE_PROVIDER=local
FILE_STORAGE_DIR=/tmp/uploads
# Optional: S3-compatible storage
# FILE_STORAGE_PROVIDER=s3
# S3_ENDPOINT=https://your-s3-endpoint
# S3_REGION=us-east-1
# S3_BUCKET=productier
# S3_ACCESS_KEY=your-access-key
# S3_SECRET_KEY=your-secret-key
# S3_USE_PATH_STYLE=false
# Optional: Metrics auth token
# METRICS_AUTH_TOKEN=your-metrics-token
# Database migrations directory
DB_MIGRATIONS_DIR=/app/migrations
+11
View File
@@ -0,0 +1,11 @@
version: "2"
sql:
- engine: "postgresql"
queries: "internal/db/queries"
schema: "internal/db/migrations"
gen:
go:
package: "db"
out: "internal/db/generated"
sql_package: "database/sql"