initiall commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:03:31 +02:00
commit 7ddfb1f52b
276 changed files with 37629 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine
WORKDIR /workspace
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/auth/package.json ./apps/auth/package.json
COPY apps/frontend/package.json ./apps/frontend/package.json
COPY packages/api-client/package.json ./packages/api-client/package.json
COPY packages/shared-types/package.json ./packages/shared-types/package.json
RUN npm ci
COPY . .
RUN npm run build --workspace @primora/auth
EXPOSE 3001
CMD ["node", "apps/auth/dist/index.js"]
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@primora/auth",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@hono/node-server": "^1.19.4",
"@hono/zod-validator": "^0.7.2",
"better-auth": "^1.5.6",
"hono": "^4.12.9",
"jose": "^6.1.0",
"nodemailer": "^7.0.6",
"pg": "^8.16.3",
"redis": "^5.8.2",
"resend": "^6.1.2",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/nodemailer": "^7.0.2",
"@types/node": "^24.5.2",
"@types/pg": "^8.15.5",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
+84
View File
@@ -0,0 +1,84 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception";
import { createClient } from "redis";
import { auth, authPool, runAuthMigrations } from "./lib/auth.js";
import { env } from "./lib/env.js";
const redisClient = createClient({
url: env.DRAGONFLY_URL,
});
try {
await redisClient.connect();
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "dragonfly unavailable", error }));
}
await retry("auth_migrations", runAuthMigrations);
const app = new Hono();
app.use("*", requestId());
app.use("*", logger());
app.use("/auth/*", async (c, next) => {
if (!redisClient.isOpen) {
await next();
return;
}
const identifier = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
const windowKey = `rate-limit:auth:${identifier}:${new Date().toISOString().slice(0, 16)}`;
const count = await redisClient.incr(windowKey);
await redisClient.expire(windowKey, 60);
if (count > 60) {
throw new HTTPException(429, { message: "Too many auth requests" });
}
await next();
});
app.get("/health", async (c) => {
let db = "ok";
let cache = redisClient.isOpen ? "ok" : "disabled";
try {
await authPool.query("select 1");
} catch (error) {
db = error instanceof Error ? error.message : "error";
}
if (redisClient.isOpen) {
try {
await redisClient.ping();
} catch (error) {
cache = error instanceof Error ? error.message : "error";
}
}
return c.json({
status: db === "ok" ? "ok" : "degraded",
checks: { database: db, dragonfly: cache },
});
});
app.on(["GET", "POST"], "/auth/*", (c) => auth.handler(c.req.raw));
serve({
fetch: app.fetch,
port: env.AUTH_PORT,
});
async function retry(label: string, fn: () => Promise<void>) {
let lastError: unknown;
for (let attempt = 1; attempt <= 20; attempt += 1) {
try {
await fn();
return;
} catch (error) {
lastError = error;
console.warn(JSON.stringify({ level: "warn", msg: `${label}_retry`, attempt, error }));
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw lastError;
}
+105
View File
@@ -0,0 +1,105 @@
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration";
import { Pool } from "pg";
import { sendTransactionalEmail } from "./mail.js";
import { env } from "./env.js";
export const authPool = new Pool({
connectionString: env.DATABASE_URL,
});
const socialProviders = {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
}
: {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
? {
discord: {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
},
}
: {}),
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
? {
microsoft: {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
tenantId: env.MICROSOFT_TENANT_ID,
},
}
: {}),
};
export const auth = betterAuth({
appName: "Primora",
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL],
database: authPool,
emailAndPassword: {
enabled: true,
async sendResetPassword({ user, url }) {
await sendTransactionalEmail({
to: user.email,
subject: "Reset your Primora password",
text: `Reset your Primora password: ${url}`,
html: `<p>Reset your Primora password.</p><p><a href="${url}">Reset password</a></p>`,
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) {
await sendTransactionalEmail({
to: user.email,
subject: "Verify your Primora email",
text: `Verify your Primora email: ${url}`,
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
});
},
},
socialProviders,
plugins: [
jwt({
jwt: {
issuer: env.JWT_ISSUER,
audience: env.JWT_AUDIENCE,
expirationTime: `${env.JWT_TTL_SECONDS} seconds`,
getSubject({ user }) {
return user.id;
},
definePayload({ user, session }) {
return {
sid: session.id,
email: user.email,
email_verified: user.emailVerified,
name: user.name,
};
},
},
}),
],
});
export async function runAuthMigrations() {
const migrations = await getMigrations(auth.options);
await migrations.runMigrations();
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.string().default("development"),
AUTH_PORT: z.string().default("3001"),
DATABASE_URL: z.string().min(1),
DRAGONFLY_URL: z.string().default("redis://localhost:6379/0"),
BETTER_AUTH_SECRET: z.string().min(16),
BETTER_AUTH_URL: z.string().url(),
AUTH_BASE_URL: z.string().url().optional(),
VITE_APP_URL: z.string().url(),
JWT_ISSUER: z.string().min(1),
JWT_AUDIENCE: z.string().min(1),
JWT_TTL_SECONDS: z.string().default("900"),
MAIL_FROM: z.string().min(1),
RESEND_API_KEY: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().default("1025"),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
MICROSOFT_CLIENT_ID: z.string().optional(),
MICROSOFT_CLIENT_SECRET: z.string().optional(),
MICROSOFT_TENANT_ID: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(
"Invalid auth environment:\n" + JSON.stringify(parsed.error.flatten().fieldErrors, null, 2),
);
}
export const env = {
...parsed.data,
AUTH_PORT: Number(parsed.data.AUTH_PORT),
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
SMTP_PORT: Number(parsed.data.SMTP_PORT),
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
};
+52
View File
@@ -0,0 +1,52 @@
import nodemailer from "nodemailer";
import { Resend } from "resend";
import { env } from "./env.js";
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
const transporter =
!resend && env.SMTP_HOST
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: false,
auth: env.SMTP_USER
? {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
}
: undefined,
})
: null;
export async function sendTransactionalEmail(input: {
to: string;
subject: string;
text: string;
html: string;
}) {
if (resend) {
await resend.emails.send({
from: env.MAIL_FROM,
to: [input.to],
subject: input.subject,
text: input.text,
html: input.html,
});
return;
}
if (!transporter) {
throw new Error("No mail transport configured for auth service.");
}
await transporter.sendMail({
from: env.MAIL_FROM,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git build-base
# Copy root configs for workspace context
COPY go.mod go.sum ./
COPY apps/backend ./apps/backend
RUN go mod download
WORKDIR /app/apps/backend
RUN go build -o /primora-backend ./cmd/server
FROM alpine:3.21
WORKDIR /app
RUN apk add --no-cache ca-certificates
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
COPY --from=builder /app/apps/backend/db ./db
COPY --from=builder /app/apps/backend/openapi ./openapi
EXPOSE 8080
CMD ["primora-backend"]
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"context"
"log"
"github.com/tdvorak/primora/apps/backend/internal/app"
)
func main() {
application, err := app.Bootstrap(context.Background())
if err != nil {
log.Fatal(err)
}
defer application.Close()
if err := application.Run(); err != nil {
log.Fatal(err)
}
}
@@ -0,0 +1,148 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS core;
CREATE TYPE core.org_role AS ENUM ('owner', 'admin', 'member');
CREATE TYPE core.project_role AS ENUM ('admin', 'developer', 'viewer');
CREATE TYPE core.bucket_visibility AS ENUM ('private', 'public');
CREATE TABLE core.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
auth_subject TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.organization_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
role core.org_role NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, user_id)
);
CREATE TABLE core.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE TABLE core.project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
role core.project_role NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, user_id)
);
CREATE TABLE core.api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
prefix TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.buckets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
visibility core.bucket_visibility NOT NULL DEFAULT 'private',
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, slug)
);
CREATE TABLE core.bucket_objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bucket_id UUID NOT NULL REFERENCES core.buckets(id) ON DELETE CASCADE,
object_key TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
checksum_sha256 TEXT NOT NULL,
storage_path TEXT NOT NULL UNIQUE,
uploaded_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (bucket_id, object_key)
);
CREATE TABLE core.project_invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
email TEXT NOT NULL,
org_role core.org_role NOT NULL DEFAULT 'member',
project_role core.project_role,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
invited_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE core.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES core.organizations(id) ON DELETE CASCADE,
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
actor_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
actor_api_key_id UUID REFERENCES core.api_keys(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
request_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_auth_subject ON core.users(auth_subject);
CREATE INDEX idx_org_members_user_id ON core.organization_members(user_id);
CREATE INDEX idx_projects_org_id ON core.projects(organization_id);
CREATE INDEX idx_project_members_user_id ON core.project_members(user_id);
CREATE INDEX idx_api_keys_project_id ON core.api_keys(project_id);
CREATE INDEX idx_api_keys_prefix ON core.api_keys(prefix);
CREATE INDEX idx_buckets_project_id ON core.buckets(project_id);
CREATE INDEX idx_bucket_objects_bucket_id ON core.bucket_objects(bucket_id);
CREATE INDEX idx_bucket_objects_bucket_key ON core.bucket_objects(bucket_id, object_key);
CREATE INDEX idx_project_invitations_email ON core.project_invitations(email);
CREATE INDEX idx_audit_logs_project_id ON core.audit_logs(project_id, created_at DESC);
CREATE INDEX idx_audit_logs_org_id ON core.audit_logs(organization_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS core.audit_logs;
DROP TABLE IF EXISTS core.project_invitations;
DROP TABLE IF EXISTS core.bucket_objects;
DROP TABLE IF EXISTS core.buckets;
DROP TABLE IF EXISTS core.api_keys;
DROP TABLE IF EXISTS core.project_members;
DROP TABLE IF EXISTS core.projects;
DROP TABLE IF EXISTS core.organization_members;
DROP TABLE IF EXISTS core.organizations;
DROP TABLE IF EXISTS core.users;
DROP TYPE IF EXISTS core.bucket_visibility;
DROP TYPE IF EXISTS core.project_role;
DROP TYPE IF EXISTS core.org_role;
DROP SCHEMA IF EXISTS core;
@@ -0,0 +1,30 @@
-- +goose Up
CREATE TABLE core.collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
schema JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, slug)
);
CREATE TABLE core.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
collection_id UUID NOT NULL REFERENCES core.collections(id) ON DELETE CASCADE,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_collections_project_id ON core.collections(project_id);
CREATE INDEX idx_documents_collection_id ON core.documents(collection_id);
CREATE INDEX idx_documents_data ON core.documents USING gin (data);
-- +goose Down
DROP TABLE IF EXISTS core.documents;
DROP TABLE IF EXISTS core.collections;
+40
View File
@@ -0,0 +1,40 @@
-- name: CreateAPIKey :one
INSERT INTO core.api_keys (
project_id,
name,
prefix,
secret_hash,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: ListAPIKeysForProject :many
SELECT * FROM core.api_keys
WHERE project_id = $1
ORDER BY created_at DESC;
-- name: GetAPIKeyByIDForProject :one
SELECT * FROM core.api_keys
WHERE project_id = $1
AND id = $2;
-- name: GetAPIKeyByPrefix :one
SELECT
ak.*,
p.organization_id
FROM core.api_keys ak
JOIN core.projects p ON p.id = ak.project_id
WHERE ak.prefix = $1;
-- name: RevokeAPIKey :one
UPDATE core.api_keys
SET revoked_at = NOW()
WHERE project_id = $1
AND id = $2
AND revoked_at IS NULL
RETURNING *;
-- name: TouchAPIKey :exec
UPDATE core.api_keys
SET last_used_at = NOW()
WHERE id = $1;
+59
View File
@@ -0,0 +1,59 @@
-- name: CreateAuditLog :one
INSERT INTO core.audit_logs (
organization_id,
project_id,
actor_user_id,
actor_api_key_id,
action,
resource_type,
resource_id,
metadata,
request_id
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
RETURNING *;
-- name: ListAuditLogsForProject :many
SELECT * FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
)
ORDER BY created_at DESC
LIMIT $4
OFFSET $5;
-- name: CountAuditLogsForProject :one
SELECT COUNT(*)::BIGINT
FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
);
+29
View File
@@ -0,0 +1,29 @@
-- name: BootstrapOrganization :one
WITH new_org AS (
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING *
),
new_org_member AS (
INSERT INTO core.organization_members (organization_id, user_id, role)
SELECT id, $3, 'owner'::core.org_role FROM new_org
),
new_project AS (
INSERT INTO core.projects (organization_id, slug, name, description)
SELECT id, $4, $5, $6 FROM new_org
RETURNING *
),
new_project_member AS (
INSERT INTO core.project_members (project_id, user_id, role)
SELECT id, $3, 'admin'::core.project_role FROM new_project
)
SELECT
new_org.id AS organization_id,
new_org.slug AS organization_slug,
new_org.name AS organization_name,
new_project.id AS project_id,
new_project.slug AS project_slug,
new_project.name AS project_name
FROM new_org
JOIN new_project ON TRUE;
+40
View File
@@ -0,0 +1,40 @@
-- name: CreateBucket :one
INSERT INTO core.buckets (
project_id,
slug,
name,
visibility,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: ListBucketsForProject :many
SELECT * FROM core.buckets
WHERE project_id = $1
AND (
btrim($2) = ''
OR slug ILIKE '%' || btrim($2) || '%'
OR name ILIKE '%' || btrim($2) || '%'
)
ORDER BY created_at ASC;
-- name: GetBucketByID :one
SELECT
b.*,
p.organization_id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE b.id = $1;
-- name: UpdateBucketByID :one
UPDATE core.buckets
SET slug = $2,
name = $3,
visibility = $4
WHERE id = $1
RETURNING *;
-- name: DeleteBucketByID :one
DELETE FROM core.buckets
WHERE id = $1
RETURNING *;
+66
View File
@@ -0,0 +1,66 @@
-- name: ListCollections :many
SELECT * FROM core.collections
WHERE project_id = $1
ORDER BY created_at DESC;
-- name: GetCollectionBySlug :one
SELECT * FROM core.collections
WHERE project_id = $1 AND slug = $2;
-- name: GetCollectionByID :one
SELECT * FROM core.collections
WHERE id = $1;
-- name: CreateCollection :one
INSERT INTO core.collections (
project_id, slug, name, description, schema, created_by_user_id
) VALUES (
$1, $2, $3, $4, $5, $6
) RETURNING *;
-- name: UpdateCollection :one
UPDATE core.collections
SET
name = $3,
description = $4,
schema = $5,
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: DeleteCollection :exec
DELETE FROM core.collections
WHERE id = $1 AND project_id = $2;
-- name: ListDocuments :many
SELECT * FROM core.documents
WHERE collection_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: CountDocuments :one
SELECT COUNT(*) FROM core.documents
WHERE collection_id = $1;
-- name: GetDocumentByID :one
SELECT * FROM core.documents
WHERE id = $1 AND collection_id = $2;
-- name: CreateDocument :one
INSERT INTO core.documents (
collection_id, data, created_by_user_id
) VALUES (
$1, $2, $3
) RETURNING *;
-- name: UpdateDocument :one
UPDATE core.documents
SET
data = $3,
updated_at = NOW()
WHERE id = $1 AND collection_id = $2
RETURNING *;
-- name: DeleteDocument :exec
DELETE FROM core.documents
WHERE id = $1 AND collection_id = $2;
+45
View File
@@ -0,0 +1,45 @@
-- name: CreateBucketObject :one
INSERT INTO core.bucket_objects (
bucket_id,
object_key,
content_type,
size_bytes,
checksum_sha256,
storage_path,
uploaded_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListBucketObjects :many
SELECT * FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%')
ORDER BY created_at DESC
LIMIT $3
OFFSET $4;
-- name: CountBucketObjects :one
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%');
-- name: GetBucketObjectByKey :one
SELECT * FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2;
-- name: MoveBucketObject :one
UPDATE core.bucket_objects
SET bucket_id = $3,
object_key = $4,
storage_path = $5
WHERE bucket_id = $1
AND object_key = $2
RETURNING *;
-- name: DeleteBucketObjectByKey :one
DELETE FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2
RETURNING *;
+149
View File
@@ -0,0 +1,149 @@
-- name: CreateOrganization :one
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING *;
-- name: UpdateOrganizationByID :one
UPDATE core.organizations
SET slug = $2,
name = $3
WHERE id = $1
RETURNING *;
-- name: ListOrganizationsForUser :many
SELECT
o.*,
om.role AS membership_role
FROM core.organizations o
JOIN core.organization_members om ON om.organization_id = o.id
WHERE om.user_id = $1
ORDER BY o.created_at ASC;
-- name: GetOrganizationMembership :one
SELECT
om.*,
o.name AS organization_name,
o.slug AS organization_slug
FROM core.organization_members om
JOIN core.organizations o ON o.id = om.organization_id
WHERE om.organization_id = $1
AND om.user_id = $2;
-- name: ListOrganizationMembers :many
SELECT
om.organization_id,
om.user_id,
om.role,
om.created_at,
u.email,
u.name,
u.email_verified
FROM core.organization_members om
JOIN core.users u ON u.id = om.user_id
WHERE om.organization_id = $1
ORDER BY om.created_at ASC;
-- name: UpdateOrganizationMemberRole :one
UPDATE core.organization_members
SET role = $3
WHERE organization_id = $1
AND user_id = $2
RETURNING *;
-- name: RemoveOrganizationMember :one
DELETE FROM core.organization_members
WHERE organization_id = $1
AND user_id = $2
RETURNING *;
-- name: RemoveProjectMembershipsForOrganizationUser :exec
DELETE FROM core.project_members pm
USING core.projects p
WHERE pm.project_id = p.id
AND p.organization_id = $1
AND pm.user_id = $2;
-- name: CountOrganizationOwners :one
SELECT COUNT(*)::BIGINT FROM core.organization_members
WHERE organization_id = $1
AND role = 'owner';
-- name: CreateInvitation :one
INSERT INTO core.project_invitations (
organization_id,
project_id,
email,
org_role,
project_role,
token_hash,
expires_at,
invited_by_user_id
) VALUES (
$1,
$2,
LOWER($3),
$4,
$5,
$6,
$7,
$8
)
RETURNING *;
-- name: GetInvitationByTokenHash :one
SELECT * FROM core.project_invitations
WHERE token_hash = $1;
-- name: GetInvitationByIDForOrganization :one
SELECT * FROM core.project_invitations
WHERE organization_id = $1
AND id = $2;
-- name: ListInvitationsForOrganization :many
SELECT
i.id,
i.organization_id,
i.project_id,
p.name AS project_name,
i.email,
i.org_role,
i.project_role,
i.expires_at,
i.accepted_at,
i.invited_by_user_id,
i.created_at
FROM core.project_invitations i
LEFT JOIN core.projects p ON p.id = i.project_id
WHERE i.organization_id = $1
ORDER BY i.created_at DESC;
-- name: DeletePendingInvitationByIDForOrganization :one
DELETE FROM core.project_invitations
WHERE organization_id = $1
AND id = $2
AND accepted_at IS NULL
RETURNING *;
-- name: MarkInvitationAccepted :one
UPDATE core.project_invitations
SET accepted_at = NOW()
WHERE id = $1
RETURNING *;
-- name: AddOrganizationMember :one
INSERT INTO core.organization_members (organization_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING *;
-- name: ListBucketsForOrganization :many
SELECT b.id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE p.organization_id = $1;
-- name: DeleteOrganizationByID :one
DELETE FROM core.organizations
WHERE id = $1
RETURNING *;
+146
View File
@@ -0,0 +1,146 @@
-- name: ListProjectsForOrganization :many
SELECT
p.*,
pm.role AS membership_role
FROM core.projects p
LEFT JOIN core.project_members pm
ON pm.project_id = p.id
AND pm.user_id = $2
WHERE p.organization_id = $1
AND (
btrim($3) = ''
OR p.slug ILIKE '%' || btrim($3) || '%'
OR p.name ILIKE '%' || btrim($3) || '%'
OR COALESCE(p.description, '') ILIKE '%' || btrim($3) || '%'
)
ORDER BY p.created_at ASC;
-- name: CreateProject :one
INSERT INTO core.projects (
organization_id,
slug,
name,
description
) VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: UpdateProjectByID :one
UPDATE core.projects
SET slug = $2,
name = $3,
description = $4
WHERE id = $1
RETURNING *;
-- name: AddProjectMember :one
INSERT INTO core.project_members (project_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING *;
-- name: GetProjectMembership :one
SELECT
pm.*,
p.organization_id
FROM core.project_members pm
JOIN core.projects p ON p.id = pm.project_id
WHERE pm.project_id = $1
AND pm.user_id = $2;
-- name: ListProjectMembers :many
SELECT
pm.project_id,
pm.user_id,
pm.role,
pm.created_at,
u.email,
u.name,
u.email_verified
FROM core.project_members pm
JOIN core.users u ON u.id = pm.user_id
WHERE pm.project_id = $1
ORDER BY pm.created_at ASC;
-- name: UpdateProjectMemberRole :one
UPDATE core.project_members
SET role = $3
WHERE project_id = $1
AND user_id = $2
RETURNING *;
-- name: RemoveProjectMember :one
DELETE FROM core.project_members
WHERE project_id = $1
AND user_id = $2
RETURNING *;
-- name: CountProjectAdmins :one
SELECT COUNT(*)::BIGINT FROM core.project_members
WHERE project_id = $1
AND role = 'admin';
-- name: GetProjectByID :one
SELECT * FROM core.projects
WHERE id = $1;
-- name: GetProjectOverview :one
SELECT
p.id AS project_id,
p.organization_id,
p.slug AS project_slug,
p.name AS project_name,
(
SELECT COUNT(*)::BIGINT
FROM core.project_members pm
WHERE pm.project_id = p.id
) AS member_count,
(
SELECT COUNT(*)::BIGINT
FROM core.api_keys ak
WHERE ak.project_id = p.id
AND ak.revoked_at IS NULL
) AS active_api_key_count,
(
SELECT COUNT(*)::BIGINT
FROM core.buckets b
WHERE b.project_id = p.id
) AS bucket_count,
(
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_count,
(
SELECT COALESCE(SUM(bo.size_bytes), 0)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_bytes_total,
(
SELECT COUNT(*)::BIGINT
FROM core.project_invitations pi
WHERE pi.organization_id = p.organization_id
AND (pi.project_id IS NULL OR pi.project_id = p.id)
AND pi.accepted_at IS NULL
AND pi.expires_at > NOW()
) AS pending_invitation_count,
(
SELECT COUNT(*)::BIGINT
FROM core.audit_logs al
WHERE al.project_id = p.id
AND al.created_at >= NOW() - INTERVAL '24 hours'
) AS audit_events_24h,
(
SELECT MAX(al.created_at)::TIMESTAMPTZ
FROM core.audit_logs al
WHERE al.project_id = p.id
) AS last_audit_at
FROM core.projects p
WHERE p.id = $1;
-- name: DeleteProjectByID :one
DELETE FROM core.projects
WHERE id = $1
RETURNING *;
+35
View File
@@ -0,0 +1,35 @@
-- name: UpsertUser :one
INSERT INTO core.users (
auth_subject,
email,
name,
email_verified,
updated_at,
last_seen_at
) VALUES (
$1,
$2,
$3,
$4,
NOW(),
NOW()
)
ON CONFLICT (auth_subject) DO UPDATE
SET
email = EXCLUDED.email,
name = EXCLUDED.name,
email_verified = EXCLUDED.email_verified,
updated_at = NOW(),
last_seen_at = NOW()
RETURNING *;
-- name: GetUserByAuthSubject :one
SELECT * FROM core.users
WHERE auth_subject = $1;
-- name: GetUserByID :one
SELECT * FROM core.users
WHERE id = $1;
-- name: CountOrganizations :one
SELECT COUNT(*)::BIGINT FROM core.organizations;
+66
View File
@@ -0,0 +1,66 @@
module github.com/tdvorak/primora/apps/backend
go 1.26.0
require (
github.com/MicahParks/keyfunc/v3 v3.7.0
github.com/gin-gonic/gin v1.11.0
github.com/go-playground/validator/v10 v10.28.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.26.0
github.com/redis/go-redis/v9 v9.16.0
github.com/resend/resend-go/v2 v2.15.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // 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/goccy/go-json v0.10.2 // 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/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // 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-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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.42.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+166
View File
@@ -0,0 +1,166 @@
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
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/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE=
github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
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=
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=
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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+168
View File
@@ -0,0 +1,168 @@
package app
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/redis/go-redis/v9"
"github.com/tdvorak/primora/apps/backend/internal/auth"
"github.com/tdvorak/primora/apps/backend/internal/config"
"github.com/tdvorak/primora/apps/backend/internal/database"
"github.com/tdvorak/primora/apps/backend/internal/handlers"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/observability"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/services"
"github.com/tdvorak/primora/apps/backend/internal/storage"
)
type App struct {
Config config.Config
Router *gin.Engine
Logger *slog.Logger
DB *pgxpool.Pool
Redis *redis.Client
}
func Bootstrap(ctx context.Context) (*App, error) {
_ = godotenv.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
return nil, err
}
dbPool, err := database.Connect(ctx, cfg.DatabaseURL)
if err != nil {
return nil, err
}
if err := database.RunMigrationsFromPool(dbPool, database.ResolveMigrationsDir(), logger); err != nil {
return nil, err
}
var verifier *auth.Verifier
for attempt := 1; attempt <= 20; attempt++ {
verifier, err = auth.NewVerifier(ctx, cfg.AuthInternalBaseURL+"/auth/jwks", cfg.JWTIssuer, cfg.JWTAudience)
if err == nil {
break
}
logger.Warn("auth jwks unavailable, retrying", "attempt", attempt, "error", err)
time.Sleep(2 * time.Second)
}
if verifier == nil {
return nil, err
}
var redisClient *redis.Client
if options, err := redis.ParseURL(cfg.DragonflyURL); err != nil {
logger.Warn("dragonfly configuration invalid, continuing in degraded mode", "error", err)
} else {
redisClient = redis.NewClient(options)
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Warn("dragonfly unavailable, continuing in degraded mode", "error", err)
redisClient = nil
}
}
store, err := storage.NewLocalStore(cfg.StorageRoot)
if err != nil {
return nil, err
}
repo := repositories.NewCoreRepository(dbPool)
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"))
if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(middleware.RequestID())
router.Use(middleware.Logger(logger))
metrics := observability.NewMetrics()
router.Use(middleware.Metrics(metrics))
router.Use(middleware.Compression())
// CORS configuration - update AllowedOrigins for production
corsOrigins := []string{cfg.PublicURL}
if cfg.Env == "development" {
corsOrigins = append(corsOrigins, "http://localhost", "http://localhost:3000")
}
router.Use(middleware.CORS(middleware.CORSConfig{
AllowedOrigins: corsOrigins,
}))
router.Use(middleware.AuthMiddleware{
Queries: repo,
Logger: logger,
Redis: redisClient,
Verifier: verifier,
RateLimits: middleware.RateLimitConfig{
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
UserPerMinute: cfg.UserRateLimitPerMin,
},
}.ResolveActor())
handler := &handlers.HTTPHandler{
Platform: platform,
Validate: validator.New(),
Metrics: metrics,
Readiness: func(c *gin.Context) map[string]any {
status := map[string]any{
"status": "ok",
"checks": map[string]any{
"database": "ok",
"storage": "ok",
"dragonfly": "ok",
},
"metrics": metrics.GetStats(),
}
if err := dbPool.Ping(c.Request.Context()); err != nil {
status["status"] = "degraded"
status["checks"].(map[string]any)["database"] = err.Error()
}
if redisClient != nil {
if err := redisClient.Ping(c.Request.Context()).Err(); err != nil {
status["status"] = "degraded"
status["checks"].(map[string]any)["dragonfly"] = err.Error()
}
} else {
status["status"] = "degraded"
status["checks"].(map[string]any)["dragonfly"] = "disabled"
}
return status
},
}
handler.Register(router)
return &App{
Config: cfg,
Router: router,
Logger: logger,
DB: dbPool,
Redis: redisClient,
}, nil
}
func (a *App) Run() error {
address := ":" + a.Config.ServerPort
a.Logger.Info("primora backend starting", "address", address)
return a.Router.Run(address)
}
func (a *App) Close() error {
if a.Redis != nil {
if err := a.Redis.Close(); err != nil {
return fmt.Errorf("close redis: %w", err)
}
}
a.DB.Close()
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
SessionID string `json:"sid"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name,omitempty"`
jwt.RegisteredClaims
}
func ParseToken(rawToken, secret, issuer, audience string) (*Claims, error) {
token, err := jwt.ParseWithClaims(rawToken, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method %s", token.Method.Alg())
}
return []byte(secret), nil
}, jwt.WithIssuer(issuer), jwt.WithAudience(audience), jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
if claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now()) {
return nil, fmt.Errorf("token expired")
}
return claims, nil
}
type Verifier struct {
keyfunc jwt.Keyfunc
issuer string
audience string
}
func NewVerifier(ctx context.Context, jwksURL, issuer, audience string) (*Verifier, error) {
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return nil, fmt.Errorf("create jwks verifier: %w", err)
}
return &Verifier{
keyfunc: jwks.Keyfunc,
issuer: issuer,
audience: audience,
}, nil
}
func (v *Verifier) ParseToken(rawToken string) (*Claims, error) {
token, err := jwt.ParseWithClaims(rawToken, &Claims{}, v.keyfunc, jwt.WithIssuer(v.issuer), jwt.WithAudience(v.audience))
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
if claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now()) {
return nil, fmt.Errorf("token expired")
}
return claims, nil
}
+131
View File
@@ -0,0 +1,131 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Env string
ServerPort string
DatabaseURL string
DragonflyURL string
StorageRoot string
AuthInternalBaseURL string
PublicURL string
UserRateLimitPerMin int
APIKeyRateLimitPerMin int
JWTIssuer string
JWTAudience string
JWTSecret string
JWTTTLSeconds int
MailFrom string
ResendAPIKey string
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPSecure bool
}
func Load() (Config, error) {
cfg := Config{
Env: getenv("NODE_ENV", "development"),
ServerPort: getenv("BACKEND_PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
DragonflyURL: getenv("DRAGONFLY_URL", "redis://localhost:6379/0"),
StorageRoot: getenv("BACKEND_STORAGE_ROOT", "./tmp/storage"),
AuthInternalBaseURL: getenv("AUTH_INTERNAL_BASE_URL", "http://auth:3001"),
PublicURL: getenv("VITE_APP_URL", "http://localhost"),
UserRateLimitPerMin: 240,
APIKeyRateLimitPerMin: 600,
JWTIssuer: getenv("JWT_ISSUER", "primora-auth"),
JWTAudience: getenv("JWT_AUDIENCE", "primora-api"),
JWTSecret: os.Getenv("JWT_SECRET"),
MailFrom: getenv("MAIL_FROM", "Primora <no-reply@primora.local>"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
SMTPHost: getenv("SMTP_HOST", "localhost"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
}
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
if err != nil {
return Config{}, fmt.Errorf("parse SMTP_PORT: %w", err)
}
cfg.SMTPPort = smtpPort
jwtTTLSeconds, err := strconv.Atoi(getenv("JWT_TTL_SECONDS", "900"))
if err != nil {
return Config{}, fmt.Errorf("parse JWT_TTL_SECONDS: %w", err)
}
cfg.JWTTTLSeconds = jwtTTLSeconds
userRateLimitPerMin, err := parseNonNegativeIntEnv("USER_RATE_LIMIT_PER_MINUTE", cfg.UserRateLimitPerMin)
if err != nil {
return Config{}, err
}
cfg.UserRateLimitPerMin = userRateLimitPerMin
apiKeyRateLimitPerMin, err := parseNonNegativeIntEnv("API_KEY_RATE_LIMIT_PER_MINUTE", cfg.APIKeyRateLimitPerMin)
if err != nil {
return Config{}, err
}
cfg.APIKeyRateLimitPerMin = apiKeyRateLimitPerMin
smtpSecure, err := strconv.ParseBool(getenv("SMTP_SECURE", "false"))
if err != nil {
return Config{}, fmt.Errorf("parse SMTP_SECURE: %w", err)
}
cfg.SMTPSecure = smtpSecure
var missing []string
if cfg.DatabaseURL == "" {
missing = append(missing, "DATABASE_URL")
}
if cfg.JWTSecret == "" {
missing = append(missing, "JWT_SECRET")
}
if cfg.StorageRoot == "" {
missing = append(missing, "BACKEND_STORAGE_ROOT")
}
if cfg.AuthInternalBaseURL == "" {
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
}
if cfg.ResendAPIKey == "" && cfg.SMTPHost == "" {
missing = append(missing, "RESEND_API_KEY or SMTP_HOST")
}
if len(missing) > 0 {
return Config{}, errors.New("missing required environment values: " + strings.Join(missing, ", "))
}
return cfg, nil
}
func getenv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
func parseNonNegativeIntEnv(key string, fallback int) (int, error) {
raw := os.Getenv(key)
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("parse %s: %w", key, err)
}
if value < 0 {
return 0, fmt.Errorf("%s must be >= 0", key)
}
return value, nil
}
@@ -0,0 +1,58 @@
package config
import (
"strings"
"testing"
)
func setRequiredEnv(t *testing.T) {
t.Helper()
t.Setenv("DATABASE_URL", "postgres://primora:primora@localhost:5432/primora?sslmode=disable")
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("BACKEND_STORAGE_ROOT", "./tmp/storage")
t.Setenv("AUTH_INTERNAL_BASE_URL", "http://auth:3001")
t.Setenv("SMTP_HOST", "mailpit")
}
func TestLoadRateLimitDefaults(t *testing.T) {
setRequiredEnv(t)
t.Setenv("USER_RATE_LIMIT_PER_MINUTE", "")
t.Setenv("API_KEY_RATE_LIMIT_PER_MINUTE", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.UserRateLimitPerMin != 240 {
t.Fatalf("unexpected USER_RATE_LIMIT_PER_MINUTE default: %d", cfg.UserRateLimitPerMin)
}
if cfg.APIKeyRateLimitPerMin != 600 {
t.Fatalf("unexpected API_KEY_RATE_LIMIT_PER_MINUTE default: %d", cfg.APIKeyRateLimitPerMin)
}
}
func TestLoadRateLimitRejectsInvalidNumber(t *testing.T) {
setRequiredEnv(t)
t.Setenv("USER_RATE_LIMIT_PER_MINUTE", "not-a-number")
_, err := Load()
if err == nil {
t.Fatalf("expected parse error")
}
if !strings.Contains(err.Error(), "parse USER_RATE_LIMIT_PER_MINUTE") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadRateLimitRejectsNegative(t *testing.T) {
setRequiredEnv(t)
t.Setenv("API_KEY_RATE_LIMIT_PER_MINUTE", "-1")
_, err := Load()
if err == nil {
t.Fatalf("expected validation error")
}
if !strings.Contains(err.Error(), "API_KEY_RATE_LIMIT_PER_MINUTE must be >= 0") {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -0,0 +1,84 @@
package database
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
var lastErr error
for attempt := 1; attempt <= 20; attempt++ {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database config: %w", err)
}
config.MaxConnLifetime = 30 * time.Minute
config.MaxConns = 20
config.MinConns = 2
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
lastErr = fmt.Errorf("create pool: %w", err)
} else if err := pool.Ping(ctx); err != nil {
pool.Close()
lastErr = fmt.Errorf("ping database: %w", err)
} else {
return pool, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(2 * time.Second):
}
}
return nil, lastErr
}
func RunMigrations(databaseURL, migrationsDir string, logger *slog.Logger) error {
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("set goose dialect: %w", err)
}
sqlDB, err := sql.Open("pgx", databaseURL)
if err != nil {
return fmt.Errorf("open sql db: %w", err)
}
defer sqlDB.Close()
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return fmt.Errorf("goose up: %w", err)
}
logger.Info("database migrations complete", "dir", migrationsDir)
return nil
}
func RunMigrationsFromPool(pool *pgxpool.Pool, migrationsDir string, logger *slog.Logger) error {
sqlDB := stdlib.OpenDBFromPool(pool)
defer sqlDB.Close()
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("set goose dialect: %w", err)
}
if err := goose.Up(sqlDB, migrationsDir); err != nil {
return fmt.Errorf("goose up: %w", err)
}
logger.Info("database migrations complete", "dir", migrationsDir)
return nil
}
func ResolveMigrationsDir() string {
if dir := os.Getenv("BACKEND_MIGRATIONS_DIR"); dir != "" {
return dir
}
return filepath.Join("db", "migrations")
}
@@ -0,0 +1,201 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: api_keys.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createAPIKey = `-- name: CreateAPIKey :one
INSERT INTO core.api_keys (
project_id,
name,
prefix,
secret_hash,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at
`
type CreateAPIKeyParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
SecretHash []byte `json:"secret_hash"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error) {
row := q.db.QueryRow(ctx, createAPIKey,
arg.ProjectID,
arg.Name,
arg.Prefix,
arg.SecretHash,
arg.CreatedByUserID,
)
var i CoreApiKey
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getAPIKeyByIDForProject = `-- name: GetAPIKeyByIDForProject :one
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at FROM core.api_keys
WHERE project_id = $1
AND id = $2
`
type GetAPIKeyByIDForProjectParams struct {
ProjectID uuid.UUID `json:"project_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) {
row := q.db.QueryRow(ctx, getAPIKeyByIDForProject, arg.ProjectID, arg.ID)
var i CoreApiKey
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getAPIKeyByPrefix = `-- name: GetAPIKeyByPrefix :one
SELECT
ak.id, ak.project_id, ak.name, ak.prefix, ak.secret_hash, ak.created_by_user_id, ak.last_used_at, ak.revoked_at, ak.created_at,
p.organization_id
FROM core.api_keys ak
JOIN core.projects p ON p.id = ak.project_id
WHERE ak.prefix = $1
`
type GetAPIKeyByPrefixRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
SecretHash []byte `json:"secret_hash"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationID uuid.UUID `json:"organization_id"`
}
func (q *Queries) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) {
row := q.db.QueryRow(ctx, getAPIKeyByPrefix, prefix)
var i GetAPIKeyByPrefixRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
&i.OrganizationID,
)
return i, err
}
const listAPIKeysForProject = `-- name: ListAPIKeysForProject :many
SELECT id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at FROM core.api_keys
WHERE project_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) {
rows, err := q.db.Query(ctx, listAPIKeysForProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreApiKey
for rows.Next() {
var i CoreApiKey
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeAPIKey = `-- name: RevokeAPIKey :one
UPDATE core.api_keys
SET revoked_at = NOW()
WHERE project_id = $1
AND id = $2
AND revoked_at IS NULL
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at
`
type RevokeAPIKeyParams struct {
ProjectID uuid.UUID `json:"project_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error) {
row := q.db.QueryRow(ctx, revokeAPIKey, arg.ProjectID, arg.ID)
var i CoreApiKey
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Prefix,
&i.SecretHash,
&i.CreatedByUserID,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const touchAPIKey = `-- name: TouchAPIKey :exec
UPDATE core.api_keys
SET last_used_at = NOW()
WHERE id = $1
`
func (q *Queries) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, touchAPIKey, id)
return err
}
@@ -0,0 +1,175 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: audit.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countAuditLogsForProject = `-- name: CountAuditLogsForProject :one
SELECT COUNT(*)::BIGINT
FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
)
`
type CountAuditLogsForProjectParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Btrim string `json:"btrim"`
Btrim_2 string `json:"btrim_2"`
}
func (q *Queries) CountAuditLogsForProject(ctx context.Context, arg CountAuditLogsForProjectParams) (int64, error) {
row := q.db.QueryRow(ctx, countAuditLogsForProject, arg.ProjectID, arg.Btrim, arg.Btrim_2)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createAuditLog = `-- name: CreateAuditLog :one
INSERT INTO core.audit_logs (
organization_id,
project_id,
actor_user_id,
actor_api_key_id,
action,
resource_type,
resource_id,
metadata,
request_id
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
RETURNING id, organization_id, project_id, actor_user_id, actor_api_key_id, action, resource_type, resource_id, metadata, request_id, created_at
`
type CreateAuditLogParams struct {
OrganizationID pgtype.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
ActorUserID pgtype.UUID `json:"actor_user_id"`
ActorApiKeyID pgtype.UUID `json:"actor_api_key_id"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Metadata []byte `json:"metadata"`
RequestID string `json:"request_id"`
}
func (q *Queries) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error) {
row := q.db.QueryRow(ctx, createAuditLog,
arg.OrganizationID,
arg.ProjectID,
arg.ActorUserID,
arg.ActorApiKeyID,
arg.Action,
arg.ResourceType,
arg.ResourceID,
arg.Metadata,
arg.RequestID,
)
var i CoreAuditLog
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.ActorUserID,
&i.ActorApiKeyID,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Metadata,
&i.RequestID,
&i.CreatedAt,
)
return i, err
}
const listAuditLogsForProject = `-- name: ListAuditLogsForProject :many
SELECT id, organization_id, project_id, actor_user_id, actor_api_key_id, action, resource_type, resource_id, metadata, request_id, created_at FROM core.audit_logs
WHERE project_id = $1
AND (
NULLIF(TRIM($2), '') IS NULL
OR action ILIKE '%' || TRIM($2) || '%'
OR resource_type ILIKE '%' || TRIM($2) || '%'
OR resource_id ILIKE '%' || TRIM($2) || '%'
OR request_id ILIKE '%' || TRIM($2) || '%'
OR metadata::text ILIKE '%' || TRIM($2) || '%'
)
AND (
NULLIF(TRIM($3), '') IS NULL
OR action ILIKE TRIM($3) || '%'
)
ORDER BY created_at DESC
LIMIT $4
OFFSET $5
`
type ListAuditLogsForProjectParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Btrim string `json:"btrim"`
Btrim_2 string `json:"btrim_2"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogsForProject,
arg.ProjectID,
arg.Btrim,
arg.Btrim_2,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAuditLog
for rows.Next() {
var i CoreAuditLog
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.ActorUserID,
&i.ActorApiKeyID,
&i.Action,
&i.ResourceType,
&i.ResourceID,
&i.Metadata,
&i.RequestID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,81 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: bootstrap.sql
package db
import (
"context"
"github.com/google/uuid"
)
const bootstrapOrganization = `-- name: BootstrapOrganization :one
WITH new_org AS (
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING id, slug, name, created_at
),
new_org_member AS (
INSERT INTO core.organization_members (organization_id, user_id, role)
SELECT id, $3, 'owner'::core.org_role FROM new_org
),
new_project AS (
INSERT INTO core.projects (organization_id, slug, name, description)
SELECT id, $4, $5, $6 FROM new_org
RETURNING id, organization_id, slug, name, description, created_at
),
new_project_member AS (
INSERT INTO core.project_members (project_id, user_id, role)
SELECT id, $3, 'admin'::core.project_role FROM new_project
)
SELECT
new_org.id AS organization_id,
new_org.slug AS organization_slug,
new_org.name AS organization_name,
new_project.id AS project_id,
new_project.slug AS project_slug,
new_project.name AS project_name
FROM new_org
JOIN new_project ON TRUE
`
type BootstrapOrganizationParams struct {
Slug string `json:"slug"`
Name string `json:"name"`
UserID uuid.UUID `json:"user_id"`
Slug_2 string `json:"slug_2"`
Name_2 string `json:"name_2"`
Description *string `json:"description"`
}
type BootstrapOrganizationRow struct {
OrganizationID uuid.UUID `json:"organization_id"`
OrganizationSlug string `json:"organization_slug"`
OrganizationName string `json:"organization_name"`
ProjectID uuid.UUID `json:"project_id"`
ProjectSlug string `json:"project_slug"`
ProjectName string `json:"project_name"`
}
func (q *Queries) BootstrapOrganization(ctx context.Context, arg BootstrapOrganizationParams) (BootstrapOrganizationRow, error) {
row := q.db.QueryRow(ctx, bootstrapOrganization,
arg.Slug,
arg.Name,
arg.UserID,
arg.Slug_2,
arg.Name_2,
arg.Description,
)
var i BootstrapOrganizationRow
err := row.Scan(
&i.OrganizationID,
&i.OrganizationSlug,
&i.OrganizationName,
&i.ProjectID,
&i.ProjectSlug,
&i.ProjectName,
)
return i, err
}
@@ -0,0 +1,190 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: buckets.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createBucket = `-- name: CreateBucket :one
INSERT INTO core.buckets (
project_id,
slug,
name,
visibility,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, slug, name, visibility, created_by_user_id, created_at
`
type CreateBucketParams struct {
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) {
row := q.db.QueryRow(ctx, createBucket,
arg.ProjectID,
arg.Slug,
arg.Name,
arg.Visibility,
arg.CreatedByUserID,
)
var i CoreBucket
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
)
return i, err
}
const deleteBucketByID = `-- name: DeleteBucketByID :one
DELETE FROM core.buckets
WHERE id = $1
RETURNING id, project_id, slug, name, visibility, created_by_user_id, created_at
`
func (q *Queries) DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) {
row := q.db.QueryRow(ctx, deleteBucketByID, id)
var i CoreBucket
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
)
return i, err
}
const getBucketByID = `-- name: GetBucketByID :one
SELECT
b.id, b.project_id, b.slug, b.name, b.visibility, b.created_by_user_id, b.created_at,
p.organization_id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE b.id = $1
`
type GetBucketByIDRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationID uuid.UUID `json:"organization_id"`
}
func (q *Queries) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) {
row := q.db.QueryRow(ctx, getBucketByID, id)
var i GetBucketByIDRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
&i.OrganizationID,
)
return i, err
}
const listBucketsForProject = `-- name: ListBucketsForProject :many
SELECT id, project_id, slug, name, visibility, created_by_user_id, created_at FROM core.buckets
WHERE project_id = $1
AND (
btrim($2) = ''
OR slug ILIKE '%' || btrim($2) || '%'
OR name ILIKE '%' || btrim($2) || '%'
)
ORDER BY created_at ASC
`
type ListBucketsForProjectParams struct {
ProjectID uuid.UUID `json:"project_id"`
Btrim string `json:"btrim"`
}
func (q *Queries) ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error) {
rows, err := q.db.Query(ctx, listBucketsForProject, arg.ProjectID, arg.Btrim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreBucket
for rows.Next() {
var i CoreBucket
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateBucketByID = `-- name: UpdateBucketByID :one
UPDATE core.buckets
SET slug = $2,
name = $3,
visibility = $4
WHERE id = $1
RETURNING id, project_id, slug, name, visibility, created_by_user_id, created_at
`
type UpdateBucketByIDParams struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
}
func (q *Queries) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) {
row := q.db.QueryRow(ctx, updateBucketByID,
arg.ID,
arg.Slug,
arg.Name,
arg.Visibility,
)
var i CoreBucket
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Visibility,
&i.CreatedByUserID,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,344 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: collections.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const countDocuments = `-- name: CountDocuments :one
SELECT COUNT(*) FROM core.documents
WHERE collection_id = $1
`
func (q *Queries) CountDocuments(ctx context.Context, collectionID uuid.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countDocuments, collectionID)
var count int64
err := row.Scan(&count)
return count, err
}
const createCollection = `-- name: CreateCollection :one
INSERT INTO core.collections (
project_id, slug, name, description, schema, created_by_user_id
) VALUES (
$1, $2, $3, $4, $5, $6
) RETURNING id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at
`
type CreateCollectionParams struct {
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
Schema []byte `json:"schema"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) {
row := q.db.QueryRow(ctx, createCollection,
arg.ProjectID,
arg.Slug,
arg.Name,
arg.Description,
arg.Schema,
arg.CreatedByUserID,
)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createDocument = `-- name: CreateDocument :one
INSERT INTO core.documents (
collection_id, data, created_by_user_id
) VALUES (
$1, $2, $3
) RETURNING id, collection_id, data, created_by_user_id, created_at, updated_at
`
type CreateDocumentParams struct {
CollectionID uuid.UUID `json:"collection_id"`
Data []byte `json:"data"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) {
row := q.db.QueryRow(ctx, createDocument, arg.CollectionID, arg.Data, arg.CreatedByUserID)
var i CoreDocument
err := row.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteCollection = `-- name: DeleteCollection :exec
DELETE FROM core.collections
WHERE id = $1 AND project_id = $2
`
type DeleteCollectionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error {
_, err := q.db.Exec(ctx, deleteCollection, arg.ID, arg.ProjectID)
return err
}
const deleteDocument = `-- name: DeleteDocument :exec
DELETE FROM core.documents
WHERE id = $1 AND collection_id = $2
`
type DeleteDocumentParams struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
}
func (q *Queries) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error {
_, err := q.db.Exec(ctx, deleteDocument, arg.ID, arg.CollectionID)
return err
}
const getCollectionByID = `-- name: GetCollectionByID :one
SELECT id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at FROM core.collections
WHERE id = $1
`
func (q *Queries) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) {
row := q.db.QueryRow(ctx, getCollectionByID, id)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getCollectionBySlug = `-- name: GetCollectionBySlug :one
SELECT id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at FROM core.collections
WHERE project_id = $1 AND slug = $2
`
type GetCollectionBySlugParams struct {
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
}
func (q *Queries) GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error) {
row := q.db.QueryRow(ctx, getCollectionBySlug, arg.ProjectID, arg.Slug)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDocumentByID = `-- name: GetDocumentByID :one
SELECT id, collection_id, data, created_by_user_id, created_at, updated_at FROM core.documents
WHERE id = $1 AND collection_id = $2
`
type GetDocumentByIDParams struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
}
func (q *Queries) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) {
row := q.db.QueryRow(ctx, getDocumentByID, arg.ID, arg.CollectionID)
var i CoreDocument
err := row.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listCollections = `-- name: ListCollections :many
SELECT id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at FROM core.collections
WHERE project_id = $1
ORDER BY created_at DESC
`
func (q *Queries) ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error) {
rows, err := q.db.Query(ctx, listCollections, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreCollection
for rows.Next() {
var i CoreCollection
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDocuments = `-- name: ListDocuments :many
SELECT id, collection_id, data, created_by_user_id, created_at, updated_at FROM core.documents
WHERE collection_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type ListDocumentsParams struct {
CollectionID uuid.UUID `json:"collection_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) {
rows, err := q.db.Query(ctx, listDocuments, arg.CollectionID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreDocument
for rows.Next() {
var i CoreDocument
if err := rows.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateCollection = `-- name: UpdateCollection :one
UPDATE core.collections
SET
name = $3,
description = $4,
schema = $5,
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, slug, name, description, schema, created_by_user_id, created_at, updated_at
`
type UpdateCollectionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Description *string `json:"description"`
Schema []byte `json:"schema"`
}
func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) {
row := q.db.QueryRow(ctx, updateCollection,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Description,
arg.Schema,
)
var i CoreCollection
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Slug,
&i.Name,
&i.Description,
&i.Schema,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateDocument = `-- name: UpdateDocument :one
UPDATE core.documents
SET
data = $3,
updated_at = NOW()
WHERE id = $1 AND collection_id = $2
RETURNING id, collection_id, data, created_by_user_id, created_at, updated_at
`
type UpdateDocumentParams struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
Data []byte `json:"data"`
}
func (q *Queries) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) {
row := q.db.QueryRow(ctx, updateDocument, arg.ID, arg.CollectionID, arg.Data)
var i CoreDocument
err := row.Scan(
&i.ID,
&i.CollectionID,
&i.Data,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+267
View File
@@ -0,0 +1,267 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"database/sql/driver"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type CoreBucketVisibility string
const (
CoreBucketVisibilityPrivate CoreBucketVisibility = "private"
CoreBucketVisibilityPublic CoreBucketVisibility = "public"
)
func (e *CoreBucketVisibility) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = CoreBucketVisibility(s)
case string:
*e = CoreBucketVisibility(s)
default:
return fmt.Errorf("unsupported scan type for CoreBucketVisibility: %T", src)
}
return nil
}
type NullCoreBucketVisibility struct {
CoreBucketVisibility CoreBucketVisibility `json:"core_bucket_visibility"`
Valid bool `json:"valid"` // Valid is true if CoreBucketVisibility is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullCoreBucketVisibility) Scan(value interface{}) error {
if value == nil {
ns.CoreBucketVisibility, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.CoreBucketVisibility.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullCoreBucketVisibility) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.CoreBucketVisibility), nil
}
type CoreOrgRole string
const (
CoreOrgRoleOwner CoreOrgRole = "owner"
CoreOrgRoleAdmin CoreOrgRole = "admin"
CoreOrgRoleMember CoreOrgRole = "member"
)
func (e *CoreOrgRole) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = CoreOrgRole(s)
case string:
*e = CoreOrgRole(s)
default:
return fmt.Errorf("unsupported scan type for CoreOrgRole: %T", src)
}
return nil
}
type NullCoreOrgRole struct {
CoreOrgRole CoreOrgRole `json:"core_org_role"`
Valid bool `json:"valid"` // Valid is true if CoreOrgRole is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullCoreOrgRole) Scan(value interface{}) error {
if value == nil {
ns.CoreOrgRole, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.CoreOrgRole.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullCoreOrgRole) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.CoreOrgRole), nil
}
type CoreProjectRole string
const (
CoreProjectRoleAdmin CoreProjectRole = "admin"
CoreProjectRoleDeveloper CoreProjectRole = "developer"
CoreProjectRoleViewer CoreProjectRole = "viewer"
)
func (e *CoreProjectRole) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = CoreProjectRole(s)
case string:
*e = CoreProjectRole(s)
default:
return fmt.Errorf("unsupported scan type for CoreProjectRole: %T", src)
}
return nil
}
type NullCoreProjectRole struct {
CoreProjectRole CoreProjectRole `json:"core_project_role"`
Valid bool `json:"valid"` // Valid is true if CoreProjectRole is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullCoreProjectRole) Scan(value interface{}) error {
if value == nil {
ns.CoreProjectRole, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.CoreProjectRole.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullCoreProjectRole) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.CoreProjectRole), nil
}
type CoreApiKey struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
SecretHash []byte `json:"secret_hash"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreAuditLog struct {
ID uuid.UUID `json:"id"`
OrganizationID pgtype.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
ActorUserID pgtype.UUID `json:"actor_user_id"`
ActorApiKeyID pgtype.UUID `json:"actor_api_key_id"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Metadata []byte `json:"metadata"`
RequestID string `json:"request_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreBucket struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreBucketObject struct {
ID uuid.UUID `json:"id"`
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSha256 string `json:"checksum_sha256"`
StoragePath string `json:"storage_path"`
UploadedByUserID pgtype.UUID `json:"uploaded_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreCollection struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
Schema []byte `json:"schema"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreDocument struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
Data []byte `json:"data"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreOrganization struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreOrganizationMember struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProject struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProjectInvitation struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
Email string `json:"email"`
OrgRole string `json:"org_role"`
ProjectRole NullCoreProjectRole `json:"project_role"`
TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
InvitedByUserID pgtype.UUID `json:"invited_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProjectMember struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreUser struct {
ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
}
@@ -0,0 +1,229 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: objects.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const countBucketObjects = `-- name: CountBucketObjects :one
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%')
`
type CountBucketObjectsParams struct {
BucketID uuid.UUID `json:"bucket_id"`
Btrim string `json:"btrim"`
}
func (q *Queries) CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (int64, error) {
row := q.db.QueryRow(ctx, countBucketObjects, arg.BucketID, arg.Btrim)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createBucketObject = `-- name: CreateBucketObject :one
INSERT INTO core.bucket_objects (
bucket_id,
object_key,
content_type,
size_bytes,
checksum_sha256,
storage_path,
uploaded_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at
`
type CreateBucketObjectParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSha256 string `json:"checksum_sha256"`
StoragePath string `json:"storage_path"`
UploadedByUserID pgtype.UUID `json:"uploaded_by_user_id"`
}
func (q *Queries) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, createBucketObject,
arg.BucketID,
arg.ObjectKey,
arg.ContentType,
arg.SizeBytes,
arg.ChecksumSha256,
arg.StoragePath,
arg.UploadedByUserID,
)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
const deleteBucketObjectByKey = `-- name: DeleteBucketObjectByKey :one
DELETE FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2
RETURNING id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at
`
type DeleteBucketObjectByKeyParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
}
func (q *Queries) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, deleteBucketObjectByKey, arg.BucketID, arg.ObjectKey)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
const getBucketObjectByKey = `-- name: GetBucketObjectByKey :one
SELECT id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at FROM core.bucket_objects
WHERE bucket_id = $1
AND object_key = $2
`
type GetBucketObjectByKeyParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
}
func (q *Queries) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, getBucketObjectByKey, arg.BucketID, arg.ObjectKey)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
const listBucketObjects = `-- name: ListBucketObjects :many
SELECT id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at FROM core.bucket_objects
WHERE bucket_id = $1
AND (btrim($2) = '' OR object_key ILIKE '%' || btrim($2) || '%')
ORDER BY created_at DESC
LIMIT $3
OFFSET $4
`
type ListBucketObjectsParams struct {
BucketID uuid.UUID `json:"bucket_id"`
Btrim string `json:"btrim"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) {
rows, err := q.db.Query(ctx, listBucketObjects,
arg.BucketID,
arg.Btrim,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreBucketObject
for rows.Next() {
var i CoreBucketObject
if err := rows.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveBucketObject = `-- name: MoveBucketObject :one
UPDATE core.bucket_objects
SET bucket_id = $3,
object_key = $4,
storage_path = $5
WHERE bucket_id = $1
AND object_key = $2
RETURNING id, bucket_id, object_key, content_type, size_bytes, checksum_sha256, storage_path, uploaded_by_user_id, created_at
`
type MoveBucketObjectParams struct {
BucketID uuid.UUID `json:"bucket_id"`
ObjectKey string `json:"object_key"`
BucketID_2 uuid.UUID `json:"bucket_id_2"`
ObjectKey_2 string `json:"object_key_2"`
StoragePath string `json:"storage_path"`
}
func (q *Queries) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) {
row := q.db.QueryRow(ctx, moveBucketObject,
arg.BucketID,
arg.ObjectKey,
arg.BucketID_2,
arg.ObjectKey_2,
arg.StoragePath,
)
var i CoreBucketObject
err := row.Scan(
&i.ID,
&i.BucketID,
&i.ObjectKey,
&i.ContentType,
&i.SizeBytes,
&i.ChecksumSha256,
&i.StoragePath,
&i.UploadedByUserID,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,594 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: organizations.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const addOrganizationMember = `-- name: AddOrganizationMember :one
INSERT INTO core.organization_members (organization_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (organization_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, organization_id, user_id, role, created_at
`
type AddOrganizationMemberParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error) {
row := q.db.QueryRow(ctx, addOrganizationMember, arg.OrganizationID, arg.UserID, arg.Role)
var i CoreOrganizationMember
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const countOrganizationOwners = `-- name: CountOrganizationOwners :one
SELECT COUNT(*)::BIGINT FROM core.organization_members
WHERE organization_id = $1
AND role = 'owner'
`
func (q *Queries) CountOrganizationOwners(ctx context.Context, organizationID uuid.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countOrganizationOwners, organizationID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createInvitation = `-- name: CreateInvitation :one
INSERT INTO core.project_invitations (
organization_id,
project_id,
email,
org_role,
project_role,
token_hash,
expires_at,
invited_by_user_id
) VALUES (
$1,
$2,
LOWER($3),
$4,
$5,
$6,
$7,
$8
)
RETURNING id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at
`
type CreateInvitationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
Lower string `json:"lower"`
OrgRole string `json:"org_role"`
ProjectRole NullCoreProjectRole `json:"project_role"`
TokenHash string `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
InvitedByUserID pgtype.UUID `json:"invited_by_user_id"`
}
func (q *Queries) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, createInvitation,
arg.OrganizationID,
arg.ProjectID,
arg.Lower,
arg.OrgRole,
arg.ProjectRole,
arg.TokenHash,
arg.ExpiresAt,
arg.InvitedByUserID,
)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const createOrganization = `-- name: CreateOrganization :one
INSERT INTO core.organizations (slug, name)
VALUES ($1, $2)
RETURNING id, slug, name, created_at
`
type CreateOrganizationParams struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, createOrganization, arg.Slug, arg.Name)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const deleteOrganizationByID = `-- name: DeleteOrganizationByID :one
DELETE FROM core.organizations
WHERE id = $1
RETURNING id, slug, name, created_at
`
func (q *Queries) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, deleteOrganizationByID, id)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const deletePendingInvitationByIDForOrganization = `-- name: DeletePendingInvitationByIDForOrganization :one
DELETE FROM core.project_invitations
WHERE organization_id = $1
AND id = $2
AND accepted_at IS NULL
RETURNING id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at
`
type DeletePendingInvitationByIDForOrganizationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, deletePendingInvitationByIDForOrganization, arg.OrganizationID, arg.ID)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const getInvitationByIDForOrganization = `-- name: GetInvitationByIDForOrganization :one
SELECT id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at FROM core.project_invitations
WHERE organization_id = $1
AND id = $2
`
type GetInvitationByIDForOrganizationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
ID uuid.UUID `json:"id"`
}
func (q *Queries) GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, getInvitationByIDForOrganization, arg.OrganizationID, arg.ID)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const getInvitationByTokenHash = `-- name: GetInvitationByTokenHash :one
SELECT id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at FROM core.project_invitations
WHERE token_hash = $1
`
func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, getInvitationByTokenHash, tokenHash)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
SELECT
om.id, om.organization_id, om.user_id, om.role, om.created_at,
o.name AS organization_name,
o.slug AS organization_slug
FROM core.organization_members om
JOIN core.organizations o ON o.id = om.organization_id
WHERE om.organization_id = $1
AND om.user_id = $2
`
type GetOrganizationMembershipParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
}
type GetOrganizationMembershipRow struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationName string `json:"organization_name"`
OrganizationSlug string `json:"organization_slug"`
}
func (q *Queries) GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error) {
row := q.db.QueryRow(ctx, getOrganizationMembership, arg.OrganizationID, arg.UserID)
var i GetOrganizationMembershipRow
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.OrganizationName,
&i.OrganizationSlug,
)
return i, err
}
const listBucketsForOrganization = `-- name: ListBucketsForOrganization :many
SELECT b.id
FROM core.buckets b
JOIN core.projects p ON p.id = b.project_id
WHERE p.organization_id = $1
`
func (q *Queries) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) {
rows, err := q.db.Query(ctx, listBucketsForOrganization, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listInvitationsForOrganization = `-- name: ListInvitationsForOrganization :many
SELECT
i.id,
i.organization_id,
i.project_id,
p.name AS project_name,
i.email,
i.org_role,
i.project_role,
i.expires_at,
i.accepted_at,
i.invited_by_user_id,
i.created_at
FROM core.project_invitations i
LEFT JOIN core.projects p ON p.id = i.project_id
WHERE i.organization_id = $1
ORDER BY i.created_at DESC
`
type ListInvitationsForOrganizationRow struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectID pgtype.UUID `json:"project_id"`
ProjectName *string `json:"project_name"`
Email string `json:"email"`
OrgRole string `json:"org_role"`
ProjectRole NullCoreProjectRole `json:"project_role"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
InvitedByUserID pgtype.UUID `json:"invited_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func (q *Queries) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) {
rows, err := q.db.Query(ctx, listInvitationsForOrganization, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListInvitationsForOrganizationRow
for rows.Next() {
var i ListInvitationsForOrganizationRow
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.ProjectName,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOrganizationMembers = `-- name: ListOrganizationMembers :many
SELECT
om.organization_id,
om.user_id,
om.role,
om.created_at,
u.email,
u.name,
u.email_verified
FROM core.organization_members om
JOIN core.users u ON u.id = om.user_id
WHERE om.organization_id = $1
ORDER BY om.created_at ASC
`
type ListOrganizationMembersRow struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) {
rows, err := q.db.Query(ctx, listOrganizationMembers, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListOrganizationMembersRow
for rows.Next() {
var i ListOrganizationMembersRow
if err := rows.Scan(
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.Email,
&i.Name,
&i.EmailVerified,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOrganizationsForUser = `-- name: ListOrganizationsForUser :many
SELECT
o.id, o.slug, o.name, o.created_at,
om.role AS membership_role
FROM core.organizations o
JOIN core.organization_members om ON om.organization_id = o.id
WHERE om.user_id = $1
ORDER BY o.created_at ASC
`
type ListOrganizationsForUserRow struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
MembershipRole string `json:"membership_role"`
}
func (q *Queries) ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error) {
rows, err := q.db.Query(ctx, listOrganizationsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListOrganizationsForUserRow
for rows.Next() {
var i ListOrganizationsForUserRow
if err := rows.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
&i.MembershipRole,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markInvitationAccepted = `-- name: MarkInvitationAccepted :one
UPDATE core.project_invitations
SET accepted_at = NOW()
WHERE id = $1
RETURNING id, organization_id, project_id, email, org_role, project_role, token_hash, expires_at, accepted_at, invited_by_user_id, created_at
`
func (q *Queries) MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error) {
row := q.db.QueryRow(ctx, markInvitationAccepted, id)
var i CoreProjectInvitation
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.ProjectID,
&i.Email,
&i.OrgRole,
&i.ProjectRole,
&i.TokenHash,
&i.ExpiresAt,
&i.AcceptedAt,
&i.InvitedByUserID,
&i.CreatedAt,
)
return i, err
}
const removeOrganizationMember = `-- name: RemoveOrganizationMember :one
DELETE FROM core.organization_members
WHERE organization_id = $1
AND user_id = $2
RETURNING id, organization_id, user_id, role, created_at
`
type RemoveOrganizationMemberParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) {
row := q.db.QueryRow(ctx, removeOrganizationMember, arg.OrganizationID, arg.UserID)
var i CoreOrganizationMember
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const removeProjectMembershipsForOrganizationUser = `-- name: RemoveProjectMembershipsForOrganizationUser :exec
DELETE FROM core.project_members pm
USING core.projects p
WHERE pm.project_id = p.id
AND p.organization_id = $1
AND pm.user_id = $2
`
type RemoveProjectMembershipsForOrganizationUserParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error {
_, err := q.db.Exec(ctx, removeProjectMembershipsForOrganizationUser, arg.OrganizationID, arg.UserID)
return err
}
const updateOrganizationByID = `-- name: UpdateOrganizationByID :one
UPDATE core.organizations
SET slug = $2,
name = $3
WHERE id = $1
RETURNING id, slug, name, created_at
`
type UpdateOrganizationByIDParams struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
func (q *Queries) UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, updateOrganizationByID, arg.ID, arg.Slug, arg.Name)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const updateOrganizationMemberRole = `-- name: UpdateOrganizationMemberRole :one
UPDATE core.organization_members
SET role = $3
WHERE organization_id = $1
AND user_id = $2
RETURNING id, organization_id, user_id, role, created_at
`
type UpdateOrganizationMemberRoleParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) {
row := q.db.QueryRow(ctx, updateOrganizationMemberRole, arg.OrganizationID, arg.UserID, arg.Role)
var i CoreOrganizationMember
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,460 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: projects.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const addProjectMember = `-- name: AddProjectMember :one
INSERT INTO core.project_members (project_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, project_id, user_id, role, created_at
`
type AddProjectMemberParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, error) {
row := q.db.QueryRow(ctx, addProjectMember, arg.ProjectID, arg.UserID, arg.Role)
var i CoreProjectMember
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const countProjectAdmins = `-- name: CountProjectAdmins :one
SELECT COUNT(*)::BIGINT FROM core.project_members
WHERE project_id = $1
AND role = 'admin'
`
func (q *Queries) CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countProjectAdmins, projectID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const createProject = `-- name: CreateProject :one
INSERT INTO core.projects (
organization_id,
slug,
name,
description
) VALUES ($1, $2, $3, $4)
RETURNING id, organization_id, slug, name, description, created_at
`
type CreateProjectParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) {
row := q.db.QueryRow(ctx, createProject,
arg.OrganizationID,
arg.Slug,
arg.Name,
arg.Description,
)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const deleteProjectByID = `-- name: DeleteProjectByID :one
DELETE FROM core.projects
WHERE id = $1
RETURNING id, organization_id, slug, name, description, created_at
`
func (q *Queries) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) {
row := q.db.QueryRow(ctx, deleteProjectByID, id)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const getProjectByID = `-- name: GetProjectByID :one
SELECT id, organization_id, slug, name, description, created_at FROM core.projects
WHERE id = $1
`
func (q *Queries) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) {
row := q.db.QueryRow(ctx, getProjectByID, id)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const getProjectMembership = `-- name: GetProjectMembership :one
SELECT
pm.id, pm.project_id, pm.user_id, pm.role, pm.created_at,
p.organization_id
FROM core.project_members pm
JOIN core.projects p ON p.id = pm.project_id
WHERE pm.project_id = $1
AND pm.user_id = $2
`
type GetProjectMembershipParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
}
type GetProjectMembershipRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
OrganizationID uuid.UUID `json:"organization_id"`
}
func (q *Queries) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) {
row := q.db.QueryRow(ctx, getProjectMembership, arg.ProjectID, arg.UserID)
var i GetProjectMembershipRow
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.OrganizationID,
)
return i, err
}
const getProjectOverview = `-- name: GetProjectOverview :one
SELECT
p.id AS project_id,
p.organization_id,
p.slug AS project_slug,
p.name AS project_name,
(
SELECT COUNT(*)::BIGINT
FROM core.project_members pm
WHERE pm.project_id = p.id
) AS member_count,
(
SELECT COUNT(*)::BIGINT
FROM core.api_keys ak
WHERE ak.project_id = p.id
AND ak.revoked_at IS NULL
) AS active_api_key_count,
(
SELECT COUNT(*)::BIGINT
FROM core.buckets b
WHERE b.project_id = p.id
) AS bucket_count,
(
SELECT COUNT(*)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_count,
(
SELECT COALESCE(SUM(bo.size_bytes), 0)::BIGINT
FROM core.bucket_objects bo
JOIN core.buckets b ON b.id = bo.bucket_id
WHERE b.project_id = p.id
) AS object_bytes_total,
(
SELECT COUNT(*)::BIGINT
FROM core.project_invitations pi
WHERE pi.organization_id = p.organization_id
AND (pi.project_id IS NULL OR pi.project_id = p.id)
AND pi.accepted_at IS NULL
AND pi.expires_at > NOW()
) AS pending_invitation_count,
(
SELECT COUNT(*)::BIGINT
FROM core.audit_logs al
WHERE al.project_id = p.id
AND al.created_at >= NOW() - INTERVAL '24 hours'
) AS audit_events_24h,
(
SELECT MAX(al.created_at)::TIMESTAMPTZ
FROM core.audit_logs al
WHERE al.project_id = p.id
) AS last_audit_at
FROM core.projects p
WHERE p.id = $1
`
type GetProjectOverviewRow struct {
ProjectID uuid.UUID `json:"project_id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectSlug string `json:"project_slug"`
ProjectName string `json:"project_name"`
MemberCount int64 `json:"member_count"`
ActiveApiKeyCount int64 `json:"active_api_key_count"`
BucketCount int64 `json:"bucket_count"`
ObjectCount int64 `json:"object_count"`
ObjectBytesTotal int64 `json:"object_bytes_total"`
PendingInvitationCount int64 `json:"pending_invitation_count"`
AuditEvents24h int64 `json:"audit_events_24h"`
LastAuditAt pgtype.Timestamptz `json:"last_audit_at"`
}
func (q *Queries) GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error) {
row := q.db.QueryRow(ctx, getProjectOverview, id)
var i GetProjectOverviewRow
err := row.Scan(
&i.ProjectID,
&i.OrganizationID,
&i.ProjectSlug,
&i.ProjectName,
&i.MemberCount,
&i.ActiveApiKeyCount,
&i.BucketCount,
&i.ObjectCount,
&i.ObjectBytesTotal,
&i.PendingInvitationCount,
&i.AuditEvents24h,
&i.LastAuditAt,
)
return i, err
}
const listProjectMembers = `-- name: ListProjectMembers :many
SELECT
pm.project_id,
pm.user_id,
pm.role,
pm.created_at,
u.email,
u.name,
u.email_verified
FROM core.project_members pm
JOIN core.users u ON u.id = pm.user_id
WHERE pm.project_id = $1
ORDER BY pm.created_at ASC
`
type ListProjectMembersRow struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error) {
rows, err := q.db.Query(ctx, listProjectMembers, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectMembersRow
for rows.Next() {
var i ListProjectMembersRow
if err := rows.Scan(
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.Email,
&i.Name,
&i.EmailVerified,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsForOrganization = `-- name: ListProjectsForOrganization :many
SELECT
p.id, p.organization_id, p.slug, p.name, p.description, p.created_at,
pm.role AS membership_role
FROM core.projects p
LEFT JOIN core.project_members pm
ON pm.project_id = p.id
AND pm.user_id = $2
WHERE p.organization_id = $1
AND (
btrim($3) = ''
OR p.slug ILIKE '%' || btrim($3) || '%'
OR p.name ILIKE '%' || btrim($3) || '%'
OR COALESCE(p.description, '') ILIKE '%' || btrim($3) || '%'
)
ORDER BY p.created_at ASC
`
type ListProjectsForOrganizationParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
UserID uuid.UUID `json:"user_id"`
Btrim string `json:"btrim"`
}
type ListProjectsForOrganizationRow struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
MembershipRole NullCoreProjectRole `json:"membership_role"`
}
func (q *Queries) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) {
rows, err := q.db.Query(ctx, listProjectsForOrganization, arg.OrganizationID, arg.UserID, arg.Btrim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectsForOrganizationRow
for rows.Next() {
var i ListProjectsForOrganizationRow
if err := rows.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.MembershipRole,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const removeProjectMember = `-- name: RemoveProjectMember :one
DELETE FROM core.project_members
WHERE project_id = $1
AND user_id = $2
RETURNING id, project_id, user_id, role, created_at
`
type RemoveProjectMemberParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) {
row := q.db.QueryRow(ctx, removeProjectMember, arg.ProjectID, arg.UserID)
var i CoreProjectMember
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const updateProjectByID = `-- name: UpdateProjectByID :one
UPDATE core.projects
SET slug = $2,
name = $3,
description = $4
WHERE id = $1
RETURNING id, organization_id, slug, name, description, created_at
`
type UpdateProjectByIDParams struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
}
func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error) {
row := q.db.QueryRow(ctx, updateProjectByID,
arg.ID,
arg.Slug,
arg.Name,
arg.Description,
)
var i CoreProject
err := row.Scan(
&i.ID,
&i.OrganizationID,
&i.Slug,
&i.Name,
&i.Description,
&i.CreatedAt,
)
return i, err
}
const updateProjectMemberRole = `-- name: UpdateProjectMemberRole :one
UPDATE core.project_members
SET role = $3
WHERE project_id = $1
AND user_id = $2
RETURNING id, project_id, user_id, role, created_at
`
type UpdateProjectMemberRoleParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error) {
row := q.db.QueryRow(ctx, updateProjectMemberRole, arg.ProjectID, arg.UserID, arg.Role)
var i CoreProjectMember
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
@@ -0,0 +1,83 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"github.com/google/uuid"
)
type Querier interface {
AddOrganizationMember(ctx context.Context, arg AddOrganizationMemberParams) (CoreOrganizationMember, error)
AddProjectMember(ctx context.Context, arg AddProjectMemberParams) (CoreProjectMember, error)
BootstrapOrganization(ctx context.Context, arg BootstrapOrganizationParams) (BootstrapOrganizationRow, error)
CountAuditLogsForProject(ctx context.Context, arg CountAuditLogsForProjectParams) (int64, error)
CountBucketObjects(ctx context.Context, arg CountBucketObjectsParams) (int64, error)
CountDocuments(ctx context.Context, collectionID uuid.UUID) (int64, error)
CountOrganizationOwners(ctx context.Context, organizationID uuid.UUID) (int64, error)
CountOrganizations(ctx context.Context) (int64, error)
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
GetCollectionBySlug(ctx context.Context, arg GetCollectionBySlugParams) (CoreCollection, error)
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
ListBucketsForProject(ctx context.Context, arg ListBucketsForProjectParams) ([]CoreBucket, error)
ListCollections(ctx context.Context, projectID uuid.UUID) ([]CoreCollection, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
TouchAPIKey(ctx context.Context, id uuid.UUID) error
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
}
var _ Querier = (*Queries)(nil)
@@ -0,0 +1,119 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: users.sql
package db
import (
"context"
"github.com/google/uuid"
)
const countOrganizations = `-- name: CountOrganizations :one
SELECT COUNT(*)::BIGINT FROM core.organizations
`
func (q *Queries) CountOrganizations(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countOrganizations)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}
const getUserByAuthSubject = `-- name: GetUserByAuthSubject :one
SELECT id, auth_subject, email, name, email_verified, created_at, updated_at, last_seen_at FROM core.users
WHERE auth_subject = $1
`
func (q *Queries) GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error) {
row := q.db.QueryRow(ctx, getUserByAuthSubject, authSubject)
var i CoreUser
err := row.Scan(
&i.ID,
&i.AuthSubject,
&i.Email,
&i.Name,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastSeenAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, auth_subject, email, name, email_verified, created_at, updated_at, last_seen_at FROM core.users
WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i CoreUser
err := row.Scan(
&i.ID,
&i.AuthSubject,
&i.Email,
&i.Name,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastSeenAt,
)
return i, err
}
const upsertUser = `-- name: UpsertUser :one
INSERT INTO core.users (
auth_subject,
email,
name,
email_verified,
updated_at,
last_seen_at
) VALUES (
$1,
$2,
$3,
$4,
NOW(),
NOW()
)
ON CONFLICT (auth_subject) DO UPDATE
SET
email = EXCLUDED.email,
name = EXCLUDED.name,
email_verified = EXCLUDED.email_verified,
updated_at = NOW(),
last_seen_at = NOW()
RETURNING id, auth_subject, email, name, email_verified, created_at, updated_at, last_seen_at
`
type UpsertUserParams struct {
AuthSubject string `json:"auth_subject"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) {
row := q.db.QueryRow(ctx, upsertUser,
arg.AuthSubject,
arg.Email,
arg.Name,
arg.EmailVerified,
)
var i CoreUser
err := row.Scan(
&i.ID,
&i.AuthSubject,
&i.Email,
&i.Name,
&i.EmailVerified,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastSeenAt,
)
return i, err
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
// +build integration
package handlers_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tdvorak/primora/apps/backend/internal/handlers"
"github.com/tdvorak/primora/apps/backend/internal/observability"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/services"
"github.com/tdvorak/primora/apps/backend/internal/storage"
)
// Integration tests require a running PostgreSQL instance
// Run with: go test -tags=integration ./...
func TestHealthEndpoints(t *testing.T) {
router := setupTestRouter(t)
t.Run("liveness check", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/health/liveness", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "ok", response["status"])
})
t.Run("readiness check", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/health/readiness", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "status")
assert.Contains(t, response, "checks")
})
}
func TestAuthenticationFlow(t *testing.T) {
router := setupTestRouter(t)
t.Run("requires authentication", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/me", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
t.Run("rejects invalid token", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/me", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
func TestBootstrapFlow(t *testing.T) {
router := setupTestRouter(t)
// This test requires a valid JWT token
// In a real integration test, you would:
// 1. Create a test user in the auth service
// 2. Get a valid JWT token
// 3. Use that token to test the bootstrap endpoint
t.Skip("Requires auth service integration")
}
func setupTestRouter(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode)
// This would need to connect to a test database
// For now, we'll create a minimal setup
router := gin.New()
// Create minimal dependencies
store, err := storage.NewLocalStore(t.TempDir())
require.NoError(t, err)
// In a real integration test, you would:
// - Connect to a test PostgreSQL database
// - Run migrations
// - Create a CoreRepository
// - Create a PlatformService
// For this example, we'll just set up the health endpoints
metrics := observability.NewMetrics()
handler := &handlers.HTTPHandler{
Platform: nil, // Would be initialized with real services
Validate: validator.New(),
Metrics: metrics,
Readiness: func(c *gin.Context) map[string]any {
return map[string]any{
"status": "ok",
"checks": map[string]any{
"database": "ok",
"storage": "ok",
"dragonfly": "disabled",
},
}
},
}
handler.Register(router)
return router
}
+107
View File
@@ -0,0 +1,107 @@
package handlers
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
path := "/"
if rawQuery != "" {
path += "?" + rawQuery
}
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, path, nil)
return ctx, recorder
}
func TestParsePaginationQuery(t *testing.T) {
t.Parallel()
t.Run("uses default when absent", func(t *testing.T) {
t.Parallel()
ctx, _ := newTestContext("")
value, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if !ok {
t.Fatalf("expected parse to succeed")
}
if value != 50 {
t.Fatalf("unexpected value: %d", value)
}
})
t.Run("parses valid query", func(t *testing.T) {
t.Parallel()
ctx, _ := newTestContext("limit=25")
value, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if !ok {
t.Fatalf("expected parse to succeed")
}
if value != 25 {
t.Fatalf("unexpected value: %d", value)
}
})
t.Run("rejects invalid number", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("limit=abc")
_, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if ok {
t.Fatalf("expected parse to fail")
}
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("rejects out-of-range value", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("limit=500")
_, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
if ok {
t.Fatalf("expected parse to fail")
}
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
}
func TestHandleErrorStatusMapping(t *testing.T) {
t.Parallel()
handler := &HTTPHandler{}
t.Run("maps insufficient role to forbidden", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, errors.New("project role admin is insufficient"))
if recorder.Code != http.StatusForbidden {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps uniqueness to conflict", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, errors.New("duplicate key value violates unique constraint"))
if recorder.Code != http.StatusConflict {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps invalid input to bad request", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, errors.New("invalid invitation project scope"))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
}
@@ -0,0 +1,273 @@
package handlers
import (
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
type organizationMembershipResponse struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
MembershipRole string `json:"membership_role"`
}
type projectResponse struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
MembershipRole *string `json:"membership_role"`
}
type apiKeyResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
LastUsedAt *time.Time `json:"last_used_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
type bucketResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Visibility string `json:"visibility"`
}
type bucketObjectResponse struct {
ID string `json:"id"`
BucketID string `json:"bucket_id"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSHA256 string `json:"checksum_sha256"`
CreatedAt time.Time `json:"created_at"`
}
type auditLogResponse struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
RequestID string `json:"request_id"`
Metadata map[string]any `json:"metadata"`
}
type collectionResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
Schema map[string]any `json:"schema"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type documentResponse struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
Data map[string]any `json:"data"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toOrganizationMembershipResponse(row db.ListOrganizationsForUserRow) organizationMembershipResponse {
return organizationMembershipResponse{
ID: row.ID.String(),
Slug: row.Slug,
Name: row.Name,
MembershipRole: row.MembershipRole,
}
}
func toOrganizationMembershipResponseFromCore(row db.CoreOrganization, role string) organizationMembershipResponse {
return organizationMembershipResponse{
ID: row.ID.String(),
Slug: row.Slug,
Name: row.Name,
MembershipRole: role,
}
}
func toProjectListResponse(rows []db.ListProjectsForOrganizationRow) []projectResponse {
result := make([]projectResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toProjectFromRow(row))
}
return result
}
func toProjectFromRow(row db.ListProjectsForOrganizationRow) projectResponse {
var membershipRole *string
if row.MembershipRole.Valid {
role := string(row.MembershipRole.CoreProjectRole)
membershipRole = &role
}
return projectResponse{
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
MembershipRole: membershipRole,
}
}
func toProjectFromCore(row db.CoreProject) projectResponse {
return projectResponse{
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
MembershipRole: nil,
}
}
func toAPIKeyListResponse(rows []db.CoreApiKey) []apiKeyResponse {
result := make([]apiKeyResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toAPIKeyResponse(row))
}
return result
}
func toAPIKeyResponse(row db.CoreApiKey) apiKeyResponse {
return apiKeyResponse{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Name: row.Name,
Prefix: row.Prefix,
LastUsedAt: timestamptzPtr(row.LastUsedAt),
RevokedAt: timestamptzPtr(row.RevokedAt),
}
}
func toBucketListResponse(rows []db.CoreBucket) []bucketResponse {
result := make([]bucketResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toBucketResponse(row))
}
return result
}
func toBucketResponse(row db.CoreBucket) bucketResponse {
return bucketResponse{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Slug: row.Slug,
Name: row.Name,
Visibility: row.Visibility,
}
}
func toObjectListResponse(rows []db.CoreBucketObject) []bucketObjectResponse {
result := make([]bucketObjectResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toObjectResponse(row))
}
return result
}
func toObjectResponse(row db.CoreBucketObject) bucketObjectResponse {
createdAt := time.Time{}
if row.CreatedAt.Valid {
createdAt = row.CreatedAt.Time
}
return bucketObjectResponse{
ID: row.ID.String(),
BucketID: row.BucketID.String(),
ObjectKey: row.ObjectKey,
ContentType: row.ContentType,
SizeBytes: row.SizeBytes,
ChecksumSHA256: row.ChecksumSha256,
CreatedAt: createdAt,
}
}
func toAuditLogListResponse(rows []db.CoreAuditLog) []auditLogResponse {
result := make([]auditLogResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toAuditLogResponse(row))
}
return result
}
func toAuditLogResponse(row db.CoreAuditLog) auditLogResponse {
metadata := map[string]any{}
_ = json.Unmarshal(row.Metadata, &metadata)
createdAt := time.Time{}
if row.CreatedAt.Valid {
createdAt = row.CreatedAt.Time
}
return auditLogResponse{
ID: row.ID.String(),
CreatedAt: createdAt,
Action: row.Action,
ResourceType: row.ResourceType,
ResourceID: row.ResourceID,
RequestID: row.RequestID,
Metadata: metadata,
}
}
func toCollectionListResponse(rows []db.CoreCollection) []collectionResponse {
result := make([]collectionResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toCollectionResponse(row))
}
return result
}
func toCollectionResponse(row db.CoreCollection) collectionResponse {
schema := map[string]any{}
_ = json.Unmarshal(row.Schema, &schema)
return collectionResponse{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
Schema: schema,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func toDocumentListResponse(rows []db.CoreDocument) []documentResponse {
result := make([]documentResponse, 0, len(rows))
for _, row := range rows {
result = append(result, toDocumentResponse(row))
}
return result
}
func toDocumentResponse(row db.CoreDocument) documentResponse {
data := map[string]any{}
_ = json.Unmarshal(row.Data, &data)
return documentResponse{
ID: row.ID.String(),
CollectionID: row.CollectionID.String(),
Data: data,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func timestamptzPtr(value pgtype.Timestamptz) *time.Time {
if !value.Valid {
return nil
}
t := value.Time
return &t
}
@@ -0,0 +1,51 @@
package middleware
import (
"compress/gzip"
"io"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
var gzipPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
return w
},
}
type gzipWriter struct {
gin.ResponseWriter
writer *gzip.Writer
}
func (g *gzipWriter) Write(data []byte) (int, error) {
return g.writer.Write(data)
}
func Compression() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.Contains(c.Request.Header.Get("Accept-Encoding"), "gzip") {
c.Next()
return
}
// Skip compression for small responses or streaming
if c.Request.Method == "HEAD" || c.Request.URL.Path == "/api/v1/health/liveness" {
c.Next()
return
}
gz := gzipPool.Get().(*gzip.Writer)
defer gzipPool.Put(gz)
gz.Reset(c.Writer)
defer gz.Close()
c.Header("Content-Encoding", "gzip")
c.Header("Vary", "Accept-Encoding")
c.Writer = &gzipWriter{ResponseWriter: c.Writer, writer: gz}
c.Next()
}
}
+264
View File
@@ -0,0 +1,264 @@
package middleware
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/redis/go-redis/v9"
"github.com/tdvorak/primora/apps/backend/internal/auth"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
)
const (
requestIDKey = "request_id"
actorKey = "actor"
)
type AuthMiddleware struct {
Queries *repositories.CoreRepository
Logger *slog.Logger
Redis *redis.Client
Verifier *auth.Verifier
RateLimits RateLimitConfig
}
type RateLimitConfig struct {
APIKeyPerMinute int
UserPerMinute int
}
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.Request.Header.Get("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
}
c.Set(requestIDKey, requestID)
c.Writer.Header().Set("X-Request-ID", requestID)
c.Next()
}
}
func Logger(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.Info("request_complete",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(c),
"client_ip", c.ClientIP(),
)
}
}
func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := strings.TrimSpace(c.GetHeader("X-API-Key"))
authz := strings.TrimSpace(c.GetHeader("Authorization"))
if apiKey == "" && strings.HasPrefix(strings.ToLower(authz), "bearer ") {
token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer"))
if strings.HasPrefix(authz, "Bearer ") {
token = strings.TrimSpace(strings.TrimPrefix(authz, "Bearer "))
}
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
apiKey = ""
actor, err := m.resolveJWTActor(c.Request.Context(), token)
if err != nil {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_token", err.Error())
return
}
userIdentity := actor.AuthSubject
if actor.UserID != nil {
userIdentity = actor.UserID.String()
}
if !m.enforceRateLimit(c, "user", userIdentity, m.RateLimits.UserPerMinute, "User rate limit exceeded") {
return
}
c.Set(actorKey, actor)
c.Next()
return
}
}
if apiKey == "" {
apiKey = strings.TrimSpace(strings.TrimPrefix(authz, "ApiKey "))
}
if apiKey != "" {
actor, err := m.resolveAPIKeyActor(c.Request.Context(), apiKey)
if err != nil {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
return
}
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
return
}
c.Set(actorKey, actor)
}
c.Next()
}
}
func (m AuthMiddleware) enforceRateLimit(c *gin.Context, scope, identity string, limit int, exceededMessage string) bool {
if m.Redis == nil || limit <= 0 || identity == "" {
return true
}
key := "ratelimit:" + scope + ":" + identity + ":" + time.Now().UTC().Format("200601021504")
count, err := m.Redis.Incr(c.Request.Context(), key).Result()
if err != nil {
if m.Logger != nil {
m.Logger.Warn("rate limit increment failed", "scope", scope, "error", err)
}
return true
}
if count == 1 {
if err := m.Redis.Expire(c.Request.Context(), key, time.Minute).Err(); err != nil && m.Logger != nil {
m.Logger.Warn("rate limit expiry update failed", "scope", scope, "error", err)
}
}
ttl, err := m.Redis.TTL(c.Request.Context(), key).Result()
if err != nil {
if m.Logger != nil {
m.Logger.Warn("rate limit ttl lookup failed", "scope", scope, "error", err)
}
ttl = time.Minute
}
if ttl <= 0 {
ttl = time.Minute
}
resetSeconds := int((ttl + time.Second - 1) / time.Second)
if resetSeconds < 1 {
resetSeconds = 1
}
remaining := limit - int(count)
if remaining < 0 {
remaining = 0
}
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Header("X-RateLimit-Reset", strconv.Itoa(resetSeconds))
if count > int64(limit) {
c.Header("Retry-After", strconv.Itoa(resetSeconds))
apperrors.Abort(c, http.StatusTooManyRequests, "rate_limited", exceededMessage)
return false
}
return true
}
func RequireActor(c *gin.Context) (*models.Actor, bool) {
actor, ok := ActorFromContext(c)
if !ok {
apperrors.Abort(c, http.StatusUnauthorized, "authentication_required", "authentication is required")
return nil, false
}
return actor, true
}
func ActorFromContext(c *gin.Context) (*models.Actor, bool) {
value, ok := c.Get(actorKey)
if !ok {
return nil, false
}
actor, ok := value.(*models.Actor)
return actor, ok
}
func RequestIDFromContext(c *gin.Context) string {
value, ok := c.Get(requestIDKey)
if !ok {
return ""
}
requestID, _ := value.(string)
return requestID
}
func (m AuthMiddleware) resolveJWTActor(ctx context.Context, token string) (*models.Actor, error) {
if m.Verifier == nil {
return nil, errors.New("jwt verifier not configured")
}
claims, err := m.Verifier.ParseToken(token)
if err != nil {
return nil, err
}
user, err := m.Queries.UpsertUser(ctx, db.UpsertUserParams{
AuthSubject: claims.Subject,
Email: strings.ToLower(claims.Email),
Name: claims.Name,
EmailVerified: claims.EmailVerified,
})
if err != nil {
return nil, fmt.Errorf("upsert user: %w", err)
}
return &models.Actor{
Type: models.ActorTypeUser,
UserID: &user.ID,
AuthSubject: claims.Subject,
Email: strings.ToLower(claims.Email),
EmailVerified: claims.EmailVerified,
Name: claims.Name,
SessionID: claims.SessionID,
}, nil
}
func (m AuthMiddleware) resolveAPIKeyActor(ctx context.Context, rawKey string) (*models.Actor, error) {
parts := strings.Split(rawKey, "_")
if len(parts) < 3 {
return nil, errors.New("malformed api key")
}
prefix := strings.Join(parts[:2], "_")
row, err := m.Queries.GetAPIKeyByPrefix(ctx, prefix)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("unknown api key")
}
return nil, err
}
if row.RevokedAt.Valid {
return nil, errors.New("api key revoked")
}
sum := sha256.Sum256([]byte(rawKey))
if subtle.ConstantTimeCompare(row.SecretHash, sum[:]) != 1 {
return nil, errors.New("invalid api key secret")
}
if err := m.Queries.TouchAPIKey(ctx, row.ID); err != nil {
m.Logger.Warn("failed to touch api key", "error", err)
}
projectID := row.ProjectID
orgID := row.OrganizationID
apiKeyID := row.ID
return &models.Actor{
Type: models.ActorTypeAPIKey,
ProjectID: &projectID,
OrganizationID: &orgID,
APIKeyID: &apiKeyID,
APIKeyPrefix: prefix,
}, nil
}
func PgUUID(id uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: id, Valid: true}
}
func HexDigest(input string) string {
sum := sha256.Sum256([]byte(input))
return hex.EncodeToString(sum[:])
}
+58
View File
@@ -0,0 +1,58 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
MaxAge int
}
func CORS(config CORSConfig) gin.HandlerFunc {
if len(config.AllowedMethods) == 0 {
config.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
}
if len(config.AllowedHeaders) == 0 {
config.AllowedHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-API-Key", "X-Request-ID"}
}
if config.MaxAge == 0 {
config.MaxAge = 86400
}
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
// Check if origin is allowed
allowed := false
for _, allowedOrigin := range config.AllowedOrigins {
if allowedOrigin == "*" || allowedOrigin == origin {
allowed = true
break
}
}
if allowed {
if origin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
} else if len(config.AllowedOrigins) == 1 {
c.Writer.Header().Set("Access-Control-Allow-Origin", config.AllowedOrigins[0])
}
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
c.Writer.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
c.Writer.Header().Set("Access-Control-Max-Age", string(rune(config.MaxAge)))
}
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
@@ -0,0 +1,58 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// StructuredLogger returns a middleware that logs HTTP requests with structured logging
func StructuredLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
req := c.Request
// Create a logger with request context
logger := log.With().
Str("method", req.Method).
Str("uri", req.RequestURI).
Str("remote_ip", c.ClientIP()).
Str("user_agent", req.UserAgent()).
Str("request_id", req.Header.Get("X-Request-ID")).
Logger()
// Add logger to context
c.Set("logger", &logger)
// Process request
c.Next()
// Calculate latency
latency := time.Since(start)
// Log the request
logEvent := logger.Info()
if len(c.Errors) > 0 {
logEvent = logger.Error().Err(c.Errors.Last())
}
logEvent.
Int("status", c.Writer.Status()).
Int64("bytes_out", int64(c.Writer.Size())).
Dur("latency_ms", latency).
Msg("HTTP request")
}
}
// GetLogger retrieves the logger from the Gin context
func GetLogger(c *gin.Context) *zerolog.Logger {
if logger, exists := c.Get("logger"); exists {
if l, ok := logger.(*zerolog.Logger); ok {
return l
}
}
return &log.Logger
}
@@ -0,0 +1,22 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/primora/apps/backend/internal/observability"
)
func Metrics(metrics *observability.Metrics) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
metrics.IncrementActive()
defer metrics.DecrementActive()
c.Next()
duration := time.Since(start)
isError := c.Writer.Status() >= 400
metrics.RecordRequest(duration, isError)
}
}
@@ -0,0 +1,152 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// RateLimiter implements a simple token bucket rate limiter
type RateLimiter struct {
visitors map[string]*visitor
mu sync.RWMutex
rate int // requests per window
window time.Duration // time window
}
type visitor struct {
tokens int
lastSeen time.Time
mu sync.Mutex
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
visitors: make(map[string]*visitor),
rate: rate,
window: window,
}
// Cleanup old visitors every minute
go rl.cleanupVisitors()
return rl
}
// Middleware returns a Gin middleware function
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !rl.allow(ip) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
return
}
c.Next()
}
}
// allow checks if a request from the given IP is allowed
func (rl *RateLimiter) allow(ip string) bool {
rl.mu.Lock()
v, exists := rl.visitors[ip]
if !exists {
v = &visitor{
tokens: rl.rate,
lastSeen: time.Now(),
}
rl.visitors[ip] = v
}
rl.mu.Unlock()
v.mu.Lock()
defer v.mu.Unlock()
// Refill tokens based on time passed
now := time.Now()
elapsed := now.Sub(v.lastSeen)
v.lastSeen = now
// Add tokens based on elapsed time
tokensToAdd := int(elapsed / rl.window * time.Duration(rl.rate))
v.tokens += tokensToAdd
if v.tokens > rl.rate {
v.tokens = rl.rate
}
// Check if we have tokens available
if v.tokens > 0 {
v.tokens--
return true
}
return false
}
// cleanupVisitors removes old visitors periodically
func (rl *RateLimiter) cleanupVisitors() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
for ip, v := range rl.visitors {
v.mu.Lock()
if time.Since(v.lastSeen) > rl.window*2 {
delete(rl.visitors, ip)
}
v.mu.Unlock()
}
rl.mu.Unlock()
}
}
// RateLimitByKey implements rate limiting by custom key (e.g., user ID, API key)
type KeyRateLimiter struct {
limiters map[string]*RateLimiter
mu sync.RWMutex
rate int
window time.Duration
}
// NewKeyRateLimiter creates a new key-based rate limiter
func NewKeyRateLimiter(rate int, window time.Duration) *KeyRateLimiter {
return &KeyRateLimiter{
limiters: make(map[string]*RateLimiter),
rate: rate,
window: window,
}
}
// Middleware returns a Gin middleware function that rate limits by a custom key
func (krl *KeyRateLimiter) Middleware(keyFunc func(*gin.Context) string) gin.HandlerFunc {
return func(c *gin.Context) {
key := keyFunc(c)
if key == "" {
c.Next()
return
}
krl.mu.RLock()
limiter, exists := krl.limiters[key]
krl.mu.RUnlock()
if !exists {
krl.mu.Lock()
limiter = NewRateLimiter(krl.rate, krl.window)
krl.limiters[key] = limiter
krl.mu.Unlock()
}
if !limiter.allow(key) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded for this resource"})
return
}
c.Next()
}
}
@@ -0,0 +1,38 @@
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
// Recovery returns a middleware that recovers from panics
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
err, ok := r.(error)
if !ok {
err = fmt.Errorf("%v", r)
}
// Log the panic with stack trace
log.Error().
Err(err).
Str("stack", string(debug.Stack())).
Str("method", c.Request.Method).
Str("uri", c.Request.RequestURI).
Str("remote_ip", c.ClientIP()).
Msg("Panic recovered")
// Return internal server error
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
}
}()
c.Next()
}
}
+32
View File
@@ -0,0 +1,32 @@
package models
import "github.com/google/uuid"
type ActorType string
const (
ActorTypeUser ActorType = "user"
ActorTypeAPIKey ActorType = "api_key"
)
type Actor struct {
Type ActorType
UserID *uuid.UUID
AuthSubject string
Email string
EmailVerified bool
Name string
SessionID string
ProjectID *uuid.UUID
OrganizationID *uuid.UUID
APIKeyID *uuid.UUID
APIKeyPrefix string
}
func (a *Actor) IsUser() bool {
return a != nil && a.Type == ActorTypeUser
}
func (a *Actor) IsAPIKey() bool {
return a != nil && a.Type == ActorTypeAPIKey
}
@@ -0,0 +1,58 @@
package observability
import (
"sync"
"time"
)
// Metrics provides basic in-memory metrics collection
type Metrics struct {
mu sync.RWMutex
requestCount int64
errorCount int64
totalResponseTime time.Duration
activeRequests int64
}
func NewMetrics() *Metrics {
return &Metrics{}
}
func (m *Metrics) RecordRequest(duration time.Duration, isError bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.requestCount++
m.totalResponseTime += duration
if isError {
m.errorCount++
}
}
func (m *Metrics) IncrementActive() {
m.mu.Lock()
defer m.mu.Unlock()
m.activeRequests++
}
func (m *Metrics) DecrementActive() {
m.mu.Lock()
defer m.mu.Unlock()
m.activeRequests--
}
func (m *Metrics) GetStats() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
avgResponseTime := int64(0)
if m.requestCount > 0 {
avgResponseTime = m.totalResponseTime.Milliseconds() / m.requestCount
}
return map[string]any{
"total_requests": m.requestCount,
"total_errors": m.errorCount,
"active_requests": m.activeRequests,
"avg_response_time_ms": avgResponseTime,
}
}
@@ -0,0 +1,58 @@
package repositories
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
type CoreRepository struct {
pool *pgxpool.Pool
queries *db.Queries
}
func NewCoreRepository(pool *pgxpool.Pool) *CoreRepository {
return &CoreRepository{
pool: pool,
queries: db.New(pool),
}
}
func (r *CoreRepository) Queries() *db.Queries {
return r.queries
}
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
if err := fn(r.queries.WithTx(tx)); err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *CoreRepository) UpsertUser(ctx context.Context, params db.UpsertUserParams) (db.CoreUser, error) {
return r.queries.UpsertUser(ctx, params)
}
func (r *CoreRepository) CountOrganizations(ctx context.Context) (int64, error) {
return r.queries.CountOrganizations(ctx)
}
func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (db.GetAPIKeyByPrefixRow, error) {
return r.queries.GetAPIKeyByPrefix(ctx, prefix)
}
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
return r.queries.TouchAPIKey(ctx, id)
}
+17
View File
@@ -0,0 +1,17 @@
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
func Abort(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"code": code,
"message": message,
},
"status": http.StatusText(status),
})
}
@@ -0,0 +1,100 @@
package services
import (
"context"
"database/sql"
"time"
)
// HealthService provides health check functionality
type HealthService struct {
db *sql.DB
}
// NewHealthService creates a new health service
func NewHealthService(db *sql.DB) *HealthService {
return &HealthService{
db: db,
}
}
// HealthStatus represents the health status of the application
type HealthStatus struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
Checks map[string]CheckResult `json:"checks"`
}
// CheckResult represents the result of a health check
type CheckResult struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Latency time.Duration `json:"latency_ms"`
}
// Check performs all health checks
func (hs *HealthService) Check(ctx context.Context, version string) HealthStatus {
status := HealthStatus{
Status: "healthy",
Timestamp: time.Now(),
Version: version,
Checks: make(map[string]CheckResult),
}
// Database check
dbCheck := hs.checkDatabase(ctx)
status.Checks["database"] = dbCheck
if dbCheck.Status != "healthy" {
status.Status = "unhealthy"
}
// Add more checks as needed
status.Checks["api"] = CheckResult{
Status: "healthy",
Message: "API is responding",
Latency: 0,
}
return status
}
// checkDatabase checks database connectivity
func (hs *HealthService) checkDatabase(ctx context.Context) CheckResult {
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
err := hs.db.PingContext(ctx)
latency := time.Since(start)
if err != nil {
return CheckResult{
Status: "unhealthy",
Message: err.Error(),
Latency: latency,
}
}
return CheckResult{
Status: "healthy",
Message: "Database connection successful",
Latency: latency,
}
}
// Readiness checks if the service is ready to accept traffic
func (hs *HealthService) Readiness(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
err := hs.db.PingContext(ctx)
return err == nil
}
// Liveness checks if the service is alive
func (hs *HealthService) Liveness(ctx context.Context) bool {
// Simple check - if we can execute this, we're alive
return true
}
+63
View File
@@ -0,0 +1,63 @@
package services
import (
"context"
"fmt"
"net/smtp"
"strings"
"github.com/resend/resend-go/v2"
"github.com/tdvorak/primora/apps/backend/internal/config"
)
type Mailer struct {
cfg config.Config
resend *resend.Client
}
func NewMailer(cfg config.Config) *Mailer {
var resendClient *resend.Client
if cfg.ResendAPIKey != "" {
resendClient = resend.NewClient(cfg.ResendAPIKey)
}
return &Mailer{cfg: cfg, resend: resendClient}
}
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
if m.resend != nil {
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
From: m.cfg.MailFrom,
To: []string{toEmail},
Subject: subject,
Text: text,
})
return err
}
address := fmt.Sprintf("%s:%d", m.cfg.SMTPHost, m.cfg.SMTPPort)
message := strings.Join([]string{
"From: " + m.cfg.MailFrom,
"To: " + toEmail,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
text,
}, "\r\n")
var auth smtp.Auth
if m.cfg.SMTPUser != "" {
auth = smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPassword, m.cfg.SMTPHost)
}
return smtp.SendMail(address, auth, extractEmail(m.cfg.MailFrom), []string{toEmail}, []byte(message))
}
func extractEmail(input string) string {
if start := strings.Index(input, "<"); start >= 0 {
if end := strings.Index(input, ">"); end > start {
return input[start+1 : end]
}
}
return input
}
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type PutResult struct {
Path string
SizeBytes int64
SHA256Digest string
}
type LocalStore struct {
root string
}
func NewLocalStore(root string) (*LocalStore, error) {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create storage root: %w", err)
}
return &LocalStore{root: root}, nil
}
func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) {
cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil {
return PutResult{}, err
}
dir := filepath.Join(s.root, bucketID)
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, cleanKey)), 0o755); err != nil {
return PutResult{}, fmt.Errorf("create object directory: %w", err)
}
path := filepath.Join(dir, cleanKey)
tmpPath := path + ".tmp"
file, err := os.Create(tmpPath)
if err != nil {
return PutResult{}, fmt.Errorf("create temp object: %w", err)
}
defer file.Close()
hasher := sha256.New()
writer := io.MultiWriter(file, hasher)
written, err := copyWithContext(ctx, writer, reader)
if err != nil {
_ = os.Remove(tmpPath)
return PutResult{}, err
}
if err := file.Close(); err != nil {
return PutResult{}, fmt.Errorf("close temp object: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return PutResult{}, fmt.Errorf("rename object: %w", err)
}
return PutResult{
Path: path,
SizeBytes: written,
SHA256Digest: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) {
cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil {
return nil, "", err
}
path := filepath.Join(s.root, bucketID, cleanKey)
file, err := os.Open(path)
if err != nil {
return nil, "", fmt.Errorf("open object: %w", err)
}
return file, path, nil
}
func (s *LocalStore) Delete(bucketID, objectKey string) error {
cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil {
return err
}
if err := os.Remove(filepath.Join(s.root, bucketID, cleanKey)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
func (s *LocalStore) Move(bucketID, fromKey, toKey string) (string, error) {
return s.MoveBetweenBuckets(bucketID, bucketID, fromKey, toKey)
}
func (s *LocalStore) MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) {
cleanFrom, err := sanitizeObjectKey(fromKey)
if err != nil {
return "", err
}
cleanTo, err := sanitizeObjectKey(toKey)
if err != nil {
return "", err
}
fromPath := filepath.Join(s.root, strings.TrimSpace(sourceBucketID), cleanFrom)
toPath := filepath.Join(s.root, strings.TrimSpace(destinationBucketID), cleanTo)
if err := os.MkdirAll(filepath.Dir(toPath), 0o755); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
if err := os.Rename(fromPath, toPath); err != nil {
return "", fmt.Errorf("move object: %w", err)
}
return toPath, nil
}
func (s *LocalStore) DeleteBucket(bucketID string) error {
bucketPath := filepath.Join(s.root, strings.TrimSpace(bucketID))
if err := os.RemoveAll(bucketPath); err != nil {
return fmt.Errorf("delete bucket directory: %w", err)
}
return nil
}
func sanitizeObjectKey(key string) (string, error) {
clean := filepath.Clean(strings.TrimSpace(key))
if clean == "." || clean == "" || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") || strings.HasPrefix(clean, "/") {
return "", fmt.Errorf("invalid object key")
}
return clean, nil
}
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
buffer := make([]byte, 32*1024)
var written int64
for {
select {
case <-ctx.Done():
return written, ctx.Err()
default:
}
nr, er := src.Read(buffer)
if nr > 0 {
nw, ew := dst.Write(buffer[:nr])
written += int64(nw)
if ew != nil {
return written, ew
}
if nr != nw {
return written, io.ErrShortWrite
}
}
if er != nil {
if er == io.EOF {
return written, nil
}
return written, er
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"testing"
)
func TestSanitizeObjectKey(t *testing.T) {
t.Parallel()
valid, err := sanitizeObjectKey(" docs/readme.txt ")
if err != nil {
t.Fatalf("expected valid key, got error: %v", err)
}
if valid != "docs/readme.txt" {
t.Fatalf("unexpected sanitized key: %s", valid)
}
invalidKeys := []string{"", ".", "../secret", "/absolute/path", " /../../etc "}
for _, key := range invalidKeys {
if _, err := sanitizeObjectKey(key); err == nil {
t.Fatalf("expected key %q to be invalid", key)
}
}
}
func TestLocalStorePutOpenDeleteRoundTrip(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
content := []byte("hello primora")
put, err := store.Put(context.Background(), "bucket-a", "docs/hello.txt", bytes.NewReader(content))
if err != nil {
t.Fatalf("put object: %v", err)
}
if put.SizeBytes != int64(len(content)) {
t.Fatalf("unexpected size: %d", put.SizeBytes)
}
expectedDigest := sha256.Sum256(content)
if put.SHA256Digest != hex.EncodeToString(expectedDigest[:]) {
t.Fatalf("unexpected digest: %s", put.SHA256Digest)
}
file, path, err := store.Open("bucket-a", "docs/hello.txt")
if err != nil {
t.Fatalf("open object: %v", err)
}
defer file.Close()
if filepath.Clean(path) != filepath.Clean(put.Path) {
t.Fatalf("path mismatch: got %s, want %s", path, put.Path)
}
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read object: %v", err)
}
if !bytes.Equal(data, content) {
t.Fatalf("content mismatch: got %q, want %q", string(data), string(content))
}
if err := store.Delete("bucket-a", "docs/hello.txt"); err != nil {
t.Fatalf("delete object: %v", err)
}
if _, _, err := store.Open("bucket-a", "docs/hello.txt"); err == nil {
t.Fatalf("expected open to fail after delete")
}
}
func TestLocalStoreDeleteBucketRemovesAllFiles(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
if _, err := store.Put(context.Background(), "bucket-z", "a.txt", bytes.NewReader([]byte("a"))); err != nil {
t.Fatalf("put a.txt: %v", err)
}
if _, err := store.Put(context.Background(), "bucket-z", "nested/b.txt", bytes.NewReader([]byte("b"))); err != nil {
t.Fatalf("put b.txt: %v", err)
}
if err := store.DeleteBucket("bucket-z"); err != nil {
t.Fatalf("delete bucket: %v", err)
}
_, err = os.Stat(filepath.Join(root, "bucket-z"))
if !os.IsNotExist(err) {
t.Fatalf("expected bucket path to be removed, stat err: %v", err)
}
}
func TestLocalStoreMoveObject(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
content := []byte("rename me")
if _, err := store.Put(context.Background(), "bucket-r", "source/name.txt", bytes.NewReader(content)); err != nil {
t.Fatalf("put source object: %v", err)
}
newPath, err := store.Move("bucket-r", "source/name.txt", "dest/renamed.txt")
if err != nil {
t.Fatalf("move object: %v", err)
}
if filepath.Clean(newPath) != filepath.Clean(filepath.Join(root, "bucket-r", "dest/renamed.txt")) {
t.Fatalf("unexpected moved path: %s", newPath)
}
if _, _, err := store.Open("bucket-r", "source/name.txt"); err == nil {
t.Fatalf("expected old key open to fail after move")
}
file, _, err := store.Open("bucket-r", "dest/renamed.txt")
if err != nil {
t.Fatalf("open moved object: %v", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read moved object: %v", err)
}
if !bytes.Equal(data, content) {
t.Fatalf("moved content mismatch: got %q, want %q", string(data), string(content))
}
}
func TestLocalStoreMoveObjectAcrossBuckets(t *testing.T) {
t.Parallel()
root := t.TempDir()
store, err := NewLocalStore(root)
if err != nil {
t.Fatalf("new local store: %v", err)
}
content := []byte("cross bucket")
if _, err := store.Put(context.Background(), "bucket-src", "folder/source.txt", bytes.NewReader(content)); err != nil {
t.Fatalf("put source object: %v", err)
}
newPath, err := store.MoveBetweenBuckets("bucket-src", "bucket-dst", "folder/source.txt", "archive/destination.txt")
if err != nil {
t.Fatalf("move across buckets: %v", err)
}
if filepath.Clean(newPath) != filepath.Clean(filepath.Join(root, "bucket-dst", "archive/destination.txt")) {
t.Fatalf("unexpected moved path: %s", newPath)
}
if _, _, err := store.Open("bucket-src", "folder/source.txt"); err == nil {
t.Fatalf("expected source key open to fail after move")
}
file, _, err := store.Open("bucket-dst", "archive/destination.txt")
if err != nil {
t.Fatalf("open moved object: %v", err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("read moved object: %v", err)
}
if !bytes.Equal(data, content) {
t.Fatalf("moved content mismatch: got %q, want %q", string(data), string(content))
}
}
@@ -0,0 +1,178 @@
package validation
import (
"fmt"
"regexp"
"strings"
)
var (
// SlugRegex matches valid slugs (lowercase alphanumeric with hyphens)
SlugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// EmailRegex matches valid email addresses
EmailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// BucketNameRegex matches valid bucket names
BucketNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
)
// ValidationError represents a validation error
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// Error implements the error interface
func (ve ValidationError) Error() string {
return fmt.Sprintf("%s: %s", ve.Field, ve.Message)
}
// ValidationErrors is a collection of validation errors
type ValidationErrors []ValidationError
// Error implements the error interface
func (ve ValidationErrors) Error() string {
if len(ve) == 0 {
return ""
}
var messages []string
for _, err := range ve {
messages = append(messages, err.Error())
}
return strings.Join(messages, "; ")
}
// Validator provides validation functions
type Validator struct {
errors ValidationErrors
}
// New creates a new validator
func New() *Validator {
return &Validator{
errors: make(ValidationErrors, 0),
}
}
// Required checks if a field is not empty
func (v *Validator) Required(field, value string) *Validator {
if strings.TrimSpace(value) == "" {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "is required",
})
}
return v
}
// MinLength checks if a field meets minimum length
func (v *Validator) MinLength(field, value string, min int) *Validator {
if len(value) < min {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: fmt.Sprintf("must be at least %d characters", min),
})
}
return v
}
// MaxLength checks if a field doesn't exceed maximum length
func (v *Validator) MaxLength(field, value string, max int) *Validator {
if len(value) > max {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: fmt.Sprintf("must not exceed %d characters", max),
})
}
return v
}
// Email checks if a field is a valid email
func (v *Validator) Email(field, value string) *Validator {
if value != "" && !EmailRegex.MatchString(value) {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be a valid email address",
})
}
return v
}
// Slug checks if a field is a valid slug
func (v *Validator) Slug(field, value string) *Validator {
if value != "" && !SlugRegex.MatchString(value) {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be a valid slug (lowercase letters, numbers, and hyphens)",
})
}
return v
}
// BucketName checks if a field is a valid bucket name
func (v *Validator) BucketName(field, value string) *Validator {
if value != "" {
if !BucketNameRegex.MatchString(value) {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be a valid bucket name (lowercase letters, numbers, and hyphens, cannot start or end with hyphen)",
})
}
if len(value) < 3 || len(value) > 63 {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: "must be between 3 and 63 characters",
})
}
}
return v
}
// OneOf checks if a field value is one of the allowed values
func (v *Validator) OneOf(field, value string, allowed []string) *Validator {
if value != "" {
found := false
for _, a := range allowed {
if value == a {
found = true
break
}
}
if !found {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: fmt.Sprintf("must be one of: %s", strings.Join(allowed, ", ")),
})
}
}
return v
}
// Custom adds a custom validation error
func (v *Validator) Custom(field, message string) *Validator {
v.errors = append(v.errors, ValidationError{
Field: field,
Message: message,
})
return v
}
// Valid returns true if there are no validation errors
func (v *Validator) Valid() bool {
return len(v.errors) == 0
}
// Errors returns all validation errors
func (v *Validator) Errors() ValidationErrors {
return v.errors
}
// FirstError returns the first validation error or nil
func (v *Validator) FirstError() error {
if len(v.errors) > 0 {
return v.errors[0]
}
return nil
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
version: "2"
sql:
- engine: "postgresql"
schema:
- "db/migrations"
queries:
- "db/queries"
gen:
go:
package: "db"
out: "internal/database/db"
sql_package: "pgx/v5"
emit_interface: true
emit_json_tags: true
emit_pointers_for_null_types: true
overrides:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "core.org_role"
go_type: "string"
- db_type: "core.project_role"
go_type: "string"
- db_type: "core.bucket_visibility"
go_type: "string"
+578
View File
@@ -0,0 +1,578 @@
# Primora Component Quick Reference
A quick reference guide for using Primora's enhanced UI components.
---
## 🚀 Quick Start
```tsx
import {
Button,
Card,
Modal,
Tooltip,
Dropdown,
Progress,
Tabs,
toast,
ToastContainer,
} from "./components";
```
---
## 📦 Components
### Button
```tsx
// Primary action
<Button variant="primary" onClick={handleClick}>
Create Project
</Button>
// With icon
<Button variant="secondary" icon={<Icons.Plus />}>
Add Item
</Button>
// Loading state
<Button variant="primary" loading={isSubmitting()}>
Saving...
</Button>
// Sizes
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
// Variants
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
```
### Card
```tsx
// Basic card
<Card>
<CardHeader title="Project Details" />
<p>Card content goes here</p>
</Card>
// With eyebrow and description
<Card variant="elevated">
<CardHeader
eyebrow="Overview"
title="Dashboard"
description="Monitor your metrics"
/>
<CardContent>
{/* Content */}
</CardContent>
<CardFooter align="right">
<Button>Action</Button>
</CardFooter>
</Card>
// Stat card
<StatCard
label="Total Users"
value={1234}
icon={<Icons.Users />}
trend="up"
trendValue="+12%"
/>
// Interactive card
<Card variant="interactive" onClick={handleClick}>
Clickable card
</Card>
```
### Modal
```tsx
const [open, setOpen] = createSignal(false);
<Modal
open={open()}
onClose={() => setOpen(false)}
title="Confirm Action"
description="Are you sure you want to proceed?"
size="md"
>
<p>Modal content</p>
<ModalFooter align="right">
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" onClick={handleConfirm}>
Confirm
</Button>
</ModalFooter>
</Modal>
```
### Tooltip
```tsx
<Tooltip content="Delete this item" placement="top">
<Button variant="ghost" icon={<Icons.Trash />} />
</Tooltip>
// With delay
<Tooltip content="Helpful hint" delay={500}>
<span>Hover me</span>
</Tooltip>
```
### Dropdown
```tsx
<Dropdown
trigger={<Button variant="secondary">Actions</Button>}
placement="bottom-end"
items={[
{
id: "edit",
label: "Edit",
icon: <Icons.Edit />,
onClick: () => handleEdit(),
},
{
id: "duplicate",
label: "Duplicate",
icon: <Icons.Copy />,
onClick: () => handleDuplicate(),
},
{ id: "divider", divider: true },
{
id: "delete",
label: "Delete",
icon: <Icons.Trash />,
danger: true,
onClick: () => handleDelete(),
},
]}
/>
```
### Progress
```tsx
// Linear progress
<Progress
value={75}
max={100}
showLabel
label="Upload Progress"
variant="success"
/>
// Circular progress
<CircularProgress
value={60}
showLabel
size={80}
variant="primary"
/>
// Spinner
<Spinner size="md" variant="primary" />
```
### Tabs
```tsx
<Tabs
variant="pills"
defaultTab="overview"
onChange={(tabId) => console.log(tabId)}
tabs={[
{
id: "overview",
label: "Overview",
icon: <Icons.Dashboard />,
content: <OverviewPanel />,
},
{
id: "settings",
label: "Settings",
badge: "3",
content: <SettingsPanel />,
},
{
id: "disabled",
label: "Disabled",
disabled: true,
content: null,
},
]}
/>
// Variants
<Tabs variant="default" tabs={...} />
<Tabs variant="pills" tabs={...} />
<Tabs variant="underline" tabs={...} />
```
### Toast
```tsx
// Add to app root
<ToastContainer />
// Use anywhere
toast.success("Operation successful!");
toast.error("Something went wrong", "Error");
toast.warning("Please review your changes");
toast.info("New update available", undefined, 10000);
// Manual control
const id = toast.show({
variant: "info",
message: "Processing...",
duration: 0, // Won't auto-dismiss
});
// Dismiss manually
toast.dismiss(id);
toast.dismissAll();
```
### Input
```tsx
// Text input
<Input
label="Project Name"
placeholder="Enter name"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
error={errors().name}
/>
// Textarea
<Textarea
label="Description"
placeholder="Enter description"
rows={4}
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
/>
// Select
<Select
label="Status"
value={status()}
onChange={(e) => setStatus(e.currentTarget.value)}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
// File input
<FileInput
label="Upload File"
accept="image/*"
onChange={(e) => setFile(e.currentTarget.files?.[0])}
/>
```
### Table
```tsx
<Table
columns={[
{ key: "name", header: "Name", width: "40%" },
{ key: "email", header: "Email" },
{
key: "status",
header: "Status",
render: (value) => <StatusBadge status={value} />
},
{
key: "actions",
header: "",
align: "right",
render: (_, row) => (
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(row)}
>
Edit
</Button>
),
},
]}
data={users()}
rowKey={(row) => row.id}
onRowClick={(row) => console.log(row)}
emptyMessage="No users found"
/>
// With pagination
<DataTable
columns={columns}
data={currentPage()}
>
<Pagination
currentPage={page()}
totalPages={totalPages()}
onPageChange={setPage}
/>
</DataTable>
```
### Badge
```tsx
<Badge variant="primary">New</Badge>
<Badge variant="success">Active</Badge>
<Badge variant="warning">Pending</Badge>
<Badge variant="error">Failed</Badge>
<Badge variant="neutral">Draft</Badge>
// Status badge
<StatusBadge status="active" />
<StatusBadge status="pending" />
<StatusBadge status="completed" />
<StatusBadge status="error" />
```
### Message
```tsx
<Message variant="info" title="Information">
This is an informational message.
</Message>
<Message variant="success" icon={<Icons.Check />}>
Operation completed successfully!
</Message>
<Message
variant="error"
dismissible
onDismiss={() => setError(null)}
>
{error()}
</Message>
```
### Layout
```tsx
<Layout
sidebar={
<Sidebar
items={navItems}
activeId={activeView()}
onSelect={setActiveView}
header={<Logo />}
footer={<UserMenu />}
/>
}
header={
<Header
title="Dashboard"
subtitle="Overview"
actions={<Button>Action</Button>}
/>
}
>
<PageHeader
eyebrow="Overview"
title="Dashboard"
description="Monitor your metrics"
actions={<Button variant="primary">Create</Button>}
/>
{/* Page content */}
</Layout>
```
---
## 🎨 CSS Utilities
### Animations
```tsx
<div class="animate-fade-in">Fade in</div>
<div class="animate-slide-up">Slide up</div>
<div class="animate-scale-in">Scale in</div>
<div class="animate-bounce-in">Bounce in</div>
```
### Effects
```tsx
<Card class="card-hover-lift">Lifts on hover</Card>
<Card class="spotlight">Shine effect</Card>
<div class="glass">Frosted glass</div>
<span class="text-shimmer">Shimmer text</span>
```
### Loading States
```tsx
<div class="skeleton h-4 w-32" />
<div class="skeleton-wave h-20 w-full" />
<SkeletonCard lines={3} />
```
### Stagger Animations
```tsx
<div class="stagger-fade-in">
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</div>
```
---
## 🎯 Common Patterns
### Form with Validation
```tsx
<form onSubmit={handleSubmit} class="space-y-4">
<Input
label="Email"
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
error={errors().email}
/>
<Input
label="Password"
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
error={errors().password}
/>
<Button
type="submit"
variant="primary"
loading={submitting()}
class="w-full"
>
Sign In
</Button>
</form>
```
### Confirmation Dialog
```tsx
const [showConfirm, setShowConfirm] = createSignal(false);
<Modal
open={showConfirm()}
onClose={() => setShowConfirm(false)}
title="Confirm Deletion"
description="This action cannot be undone."
size="sm"
>
<ModalFooter align="right">
<Button
variant="secondary"
onClick={() => setShowConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
onClick={handleDelete}
>
Delete
</Button>
</ModalFooter>
</Modal>
```
### Dashboard Grid
```tsx
<div class="dashboard-grid">
<StatCard label="Users" value={users()} />
<StatCard label="Revenue" value={`$${revenue()}`} />
<StatCard label="Growth" value="+12%" trend="up" />
</div>
```
### Action Menu
```tsx
<Dropdown
trigger={
<Button variant="ghost" size="sm">
<Icons.Menu />
</Button>
}
items={[
{ id: "view", label: "View Details", icon: <Icons.Eye /> },
{ id: "edit", label: "Edit", icon: <Icons.Edit /> },
{ id: "divider", divider: true },
{ id: "delete", label: "Delete", icon: <Icons.Trash />, danger: true },
]}
/>
```
### Loading State
```tsx
<Show
when={!loading()}
fallback={
<div class="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
}
>
{/* Content */}
</Show>
```
### Empty State
```tsx
<EmptyState
icon={<Icons.Inbox class="h-12 w-12" />}
title="No projects yet"
description="Get started by creating your first project"
action={
<Button variant="primary" onClick={handleCreate}>
Create Project
</Button>
}
/>
```
---
## 💡 Tips
### Performance
- Use `createMemo` for expensive computations
- Leverage SolidJS fine-grained reactivity
- Avoid unnecessary re-renders
- Use `Show` instead of ternary for conditional rendering
### Accessibility
- Always provide labels for inputs
- Use semantic HTML
- Test keyboard navigation
- Ensure color contrast
### Styling
- Use Tailwind utilities first
- Leverage CSS custom properties for theming
- Keep component styles scoped
- Use consistent spacing
### State Management
- Keep state close to where it's used
- Use signals for reactive state
- Lift state only when necessary
- Consider context for global state
---
## 🔗 Related Files
- `apps/frontend/src/index.css` - Global styles and design tokens
- `apps/frontend/tailwind.config.cjs` - Tailwind configuration
- `FRONTEND_ENHANCEMENTS.md` - Detailed enhancement documentation
- `project_frontend.md` - Design system specification
---
**Happy coding! 🚀**
+37
View File
@@ -0,0 +1,37 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copy root configs for workspace context
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/auth/package.json ./apps/auth/package.json
COPY apps/frontend/package.json ./apps/frontend/package.json
COPY packages/api-client/package.json ./packages/api-client/package.json
COPY packages/shared-types/package.json ./packages/shared-types/package.json
RUN npm ci
# Copy full source
COPY . .
# Generate client
RUN npm run generate:client
# Build frontend
WORKDIR /app/apps/frontend
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html
# Add simple nginx config for SPA routing
RUN echo 'server { \
listen 80; \
location / { \
root /usr/share/nginx/html; \
index index.html; \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+292
View File
@@ -0,0 +1,292 @@
# Primora Platform - Feature Guide
## 🎯 Dashboard
### Project Dashboard
When you select a project, you'll see:
**Statistics Cards**
- Storage buckets count
- API keys count
- Team members count
- Audit log events count
**Usage Charts**
- Bandwidth usage over time
- Request count analytics
**Quick Actions**
- Create Bucket - Set up storage instantly
- Generate API Key - Authenticate your apps
- Invite Members - Add team collaborators
**Help Section**
- Documentation links
- Getting started guides
## 📁 Projects Page
### Features
- **Grid View**: Visual cards for all projects
- **Search**: Real-time filtering by name, slug, or description
- **Create Modal**: Streamlined project creation
- Auto-generates slug from name
- Optional description
- Instant validation
- **Project Cards**: Show name, slug, role, and description
- **Quick Actions**: View dashboard or manage settings
- **Settings Panel**: Edit project details (admin only)
### User Flow
1. Click "New Project" button
2. Enter project name (slug auto-generates)
3. Add optional description
4. Click "Create Project"
5. Onboarding modal appears automatically
6. Navigate to project dashboard
## 👥 Members Page
### Organization Members Tab
- **Member List**: All organization members with avatars
- **Role Management**: Inline role updates (Owner, Admin, Member)
- **Search**: Filter members by name or email
- **Remove Members**: Quick removal (except yourself)
### Project Members Tab
- **Project-Specific**: Members with project access
- **Role Options**: Admin, Developer, Viewer
- **Granular Control**: Different roles per project
### Pending Invitations
- **Visual Indicators**: Yellow highlight for pending invites
- **Invitation Details**: Email, roles, and date
- **Revoke Option**: Cancel pending invitations
### Invite Modal
- **Email Input**: Send invitations via email
- **Organization Role**: Set org-level permissions
- **Project Attachment**: Optionally add to current project
- **Project Role**: Set project-level permissions
## 💾 Storage Page
### Three-Panel Layout
**Left Panel: Buckets**
- List of all buckets
- Search functionality
- Bucket stats (object count, size)
- Visibility badges (Public/Private)
- Click to select
**Center Panel: Objects**
- Table view of files
- File details (name, size, type, date)
- Quick actions (preview, download, delete)
- Pagination for large lists
- Empty state with upload prompt
**Right Panel: Settings**
- Bucket name and slug
- Visibility toggle
- Update button
- Delete bucket (with confirmation)
### Modals
**Create Bucket**
- Name input with auto-slug
- Visibility selection
- Instant creation
**Upload File**
- File selector
- File preview before upload
- Size and type display
- Progress indication
**Object Preview**
- Image preview for images
- Text preview for code/text files
- Download prompt for other types
- Truncation warning for large files
## ⚙️ Settings Page
### API Keys Tab
- **Key List**: All API keys with status
- **Create Key**: Generate new authentication keys
- **One-Time Secret**: Secure key display (copy immediately!)
- **Key Management**: Revoke keys when needed
- **Security Warning**: Prominent security best practices
### Organization Tab
- **Organization Details**: Name and slug
- **Update Settings**: Modify organization info
- **Danger Zone**: Delete organization (owner only)
- **Warning Messages**: Clear consequences of actions
### General Tab
- **Theme Selection**: Light, Dark, System
- **Language**: Multiple language support
- **Timezone**: Set your timezone
- **Notifications**: Email, security, product updates
## 📊 Audit Page
### Statistics Dashboard
- **Total Events**: All logged activities
- **Creates**: New resource count
- **Updates**: Modified resource count
- **Deletes**: Removed resource count
### Filters
- **Search**: Find by resource, actor, or details
- **Action Filter**: Filter by action type (create, update, delete, etc.)
- **Real-time**: Instant filtering
### Audit Log Table
- **Timestamp**: When the action occurred
- **Action**: What happened (with color-coded badges)
- **Resource**: What was affected
- **Actor**: Who performed the action (with avatar)
- **Details**: Expandable JSON metadata
### Export Options
- **CSV Export**: For spreadsheet analysis
- **JSON Export**: For programmatic processing
- **Compliance**: Meet audit requirements
## 🎓 Onboarding Modal
### Step 1: Welcome
- Overview of setup process
- Visual progress indicators
- Skip option available
### Step 2: API Keys
- Explanation of API keys
- Security tips
- Link to settings
### Step 3: Integration
- Code snippet for your language
- Copy-paste ready
- Documentation link
## 🎨 Design Features
### Visual Elements
- **Modern Cards**: Clean, elevated design
- **Color-Coded Badges**: Instant status recognition
- **Smooth Animations**: Fade-ins, slide-ins, scale effects
- **Hover States**: Interactive feedback
- **Loading States**: Skeleton screens and spinners
### User Experience
- **Empty States**: Helpful messages and actions
- **Error Messages**: Clear, actionable feedback
- **Confirmation Dialogs**: Prevent accidental deletions
- **Toast Notifications**: Non-intrusive updates
- **Keyboard Navigation**: Full keyboard support
### Responsive Design
- **Mobile-First**: Works on all screen sizes
- **Touch-Friendly**: Large tap targets
- **Adaptive Layouts**: Grid to stack on mobile
- **Collapsible Panels**: Save space on small screens
## 🔐 Security Features
### API Key Management
- One-time secret display
- Secure storage recommendations
- Revocation capability
- Activity tracking
### Access Control
- Role-based permissions
- Organization-level roles
- Project-level roles
- Owner-only actions
### Audit Trail
- Complete activity log
- Actor identification
- Resource tracking
- Metadata preservation
## 🚀 Performance
### Optimizations
- **Pagination**: Load data in chunks
- **Search Debouncing**: Reduce API calls
- **Lazy Loading**: Load modals on demand
- **Efficient Rendering**: SolidJS reactivity
- **Caching**: Reduce redundant requests
### User Feedback
- **Loading Indicators**: Know when things are processing
- **Progress Bars**: Track long operations
- **Skeleton Screens**: Show structure while loading
- **Error Recovery**: Retry failed operations
## 💡 Tips & Tricks
### Keyboard Shortcuts (Future)
- `Cmd/Ctrl + K`: Command palette
- `Cmd/Ctrl + N`: New project
- `Cmd/Ctrl + ,`: Settings
- `Esc`: Close modals
### Best Practices
1. **Projects**: Use descriptive names and slugs
2. **Members**: Assign minimal required permissions
3. **Storage**: Organize with clear bucket names
4. **API Keys**: Rotate keys regularly
5. **Audit**: Review logs periodically
### Common Workflows
**Setting Up a New Project**
1. Create project
2. Complete onboarding
3. Generate API key
4. Create storage bucket
5. Invite team members
6. Start building!
**Managing Team Access**
1. Invite via email
2. Set organization role
3. Attach to projects
4. Set project roles
5. Monitor via audit logs
**Organizing Storage**
1. Create buckets by purpose
2. Set appropriate visibility
3. Upload files
4. Use consistent naming
5. Monitor usage
## 🆘 Support
### Getting Help
- **Documentation**: Comprehensive guides
- **In-App Tips**: Contextual help messages
- **Error Messages**: Actionable solutions
- **Support Team**: Contact for assistance
### Feedback
- Report bugs
- Request features
- Share suggestions
- Rate your experience
---
**Version**: 2.0.0
**Last Updated**: 2024
**Platform**: Primora
+289
View File
@@ -0,0 +1,289 @@
# Frontend Improvements Summary
## Overview
Comprehensive redesign and enhancement of the Primora platform frontend with modern UI/UX patterns, improved navigation, and dedicated page components for better maintainability.
## Major Changes
### 1. New Page Components Architecture
Created dedicated page components for better code organization and reusability:
#### **ProjectsPage** (`src/pages/ProjectsPage.tsx`)
- Modern card-based project grid layout
- Real-time search and filtering
- Inline project creation modal with auto-slug generation
- Project settings management
- Visual indicators for selected projects
- Role-based badges (admin, member, etc.)
- Quick navigation to project dashboard
#### **MembersPage** (`src/pages/MembersPage.tsx`)
- Tabbed interface for Organization and Project members
- Pending invitations section with visual indicators
- Advanced member search functionality
- Inline role management with dropdowns
- Member invitation modal with project attachment option
- Avatar generation for members
- Comprehensive member actions (remove, update role)
#### **StoragePage** (`src/pages/StoragePage.tsx`)
- Three-column layout: Buckets list, Objects table, Settings panel
- Bucket creation and management modals
- File upload with drag-and-drop support
- Object preview modal (images, text, unsupported types)
- Advanced search and filtering
- Pagination for large object lists
- Visibility badges (public/private)
- Quick actions (download, delete, preview)
#### **SettingsPage** (`src/pages/SettingsPage.tsx`)
- Tabbed interface: API Keys, Organization, General
- API key creation with secure secret display
- One-time secret viewing with copy-to-clipboard
- Organization settings management
- Danger zone for destructive actions
- General settings (theme, language, timezone)
- Notification preferences
- Security alerts and warnings
#### **AuditPage** (`src/pages/AuditPage.tsx`)
- Advanced filtering (search, action type)
- Statistics cards (total events, creates, updates, deletes)
- Color-coded action badges
- Expandable details for each log entry
- Pagination for large datasets
- Export functionality (CSV, JSON)
- Real-time refresh capability
- Visual action icons
### 2. Enhanced Dashboard
- **ProjectDashboard** component with:
- Project statistics (Storage, API Keys, Members, Audit Logs)
- Usage charts placeholders (Bandwidth, Requests)
- Quick action cards for common tasks
- Documentation links
- Modern card-based layout
### 3. Onboarding Experience
- **OnboardingModal** component with 3-step wizard:
- Step 1: Welcome and overview
- Step 2: API key creation guide
- Step 3: Code snippet for integration
- Skip functionality
- Progress indicators
- Contextual tips and warnings
### 4. Navigation Improvements
- Platform-wide navigation (not service-specific)
- Organization and Project selectors in header
- Visual separator between selectors
- Breadcrumb-style navigation
- Active state indicators
- Responsive design for mobile
### 5. Component Enhancements
#### **Tabs Component**
- Added `activeTab` prop for controlled state
- Support for external state management
- Improved accessibility
- Better visual feedback
#### **Button Component**
- Added `outline` variant
- Consistent styling across all pages
- Loading states
- Icon support
#### **Modal Component**
- Size variants (sm, md, lg, xl, full)
- Backdrop click handling
- Escape key support
- Smooth animations
### 6. UI/UX Improvements
#### Visual Design
- Consistent color scheme
- Modern card-based layouts
- Smooth transitions and animations
- Hover states for interactive elements
- Loading skeletons
- Empty states with helpful messages
#### User Experience
- Inline editing where appropriate
- Confirmation dialogs for destructive actions
- Real-time search and filtering
- Pagination for large datasets
- Toast notifications
- Error handling with user-friendly messages
#### Accessibility
- Proper ARIA labels
- Keyboard navigation support
- Focus management
- Screen reader friendly
- Color contrast compliance
### 7. Code Organization
#### File Structure
```
apps/frontend/src/
├── components/
│ ├── OnboardingModal.tsx (new)
│ ├── ProjectDashboard.tsx (new)
│ └── ... (existing components)
├── pages/
│ ├── ProjectsPage.tsx (new)
│ ├── MembersPage.tsx (new)
│ ├── StoragePage.tsx (new)
│ ├── SettingsPage.tsx (new)
│ ├── AuditPage.tsx (new)
│ └── index.ts (new)
└── App.tsx (refactored)
```
#### Benefits
- Separation of concerns
- Easier testing
- Better code reusability
- Simplified maintenance
- Clearer component hierarchy
### 8. Performance Optimizations
- Lazy loading for modals
- Efficient re-rendering with SolidJS signals
- Pagination to reduce data load
- Search debouncing (can be added)
- Virtual scrolling for large lists (infrastructure ready)
### 9. Developer Experience
- TypeScript interfaces for all props
- Consistent naming conventions
- Comprehensive prop documentation
- Reusable utility functions
- Clear component APIs
## Features Added
### Projects Management
- ✅ Grid view with search
- ✅ Modal-based creation
- ✅ Auto-slug generation
- ✅ Inline settings editing
- ✅ Role-based access control
- ✅ Quick navigation to dashboard
### Members Management
- ✅ Organization and project member tabs
- ✅ Pending invitations tracking
- ✅ Inline role updates
- ✅ Member search
- ✅ Invitation modal with project attachment
- ✅ Member removal with confirmation
### Storage Management
- ✅ Bucket list with search
- ✅ Object table with pagination
- ✅ File upload modal
- ✅ Object preview (images, text)
- ✅ Visibility management
- ✅ Bulk operations ready
### Settings Management
- ✅ API key creation with secure display
- ✅ Organization settings
- ✅ General preferences
- ✅ Notification settings
- ✅ Danger zone for destructive actions
### Audit Logging
- ✅ Advanced filtering
- ✅ Statistics dashboard
- ✅ Action categorization
- ✅ Export functionality
- ✅ Detailed log viewing
## Technical Improvements
### State Management
- Centralized state in App.tsx
- Props drilling to page components
- Signal-based reactivity
- Efficient updates
### Error Handling
- User-friendly error messages
- Network error detection
- Graceful degradation
- Retry mechanisms
### Responsive Design
- Mobile-first approach
- Breakpoint-based layouts
- Touch-friendly interactions
- Adaptive navigation
## Future Enhancements
### Potential Additions
1. Real-time updates with WebSockets
2. Advanced search with filters
3. Bulk operations for resources
4. Keyboard shortcuts
5. Dark mode support
6. Internationalization (i18n)
7. Advanced analytics dashboard
8. Custom themes
9. Drag-and-drop file uploads
10. Collaborative features
### Performance
1. Code splitting
2. Image optimization
3. Caching strategies
4. Service worker for offline support
5. Progressive Web App (PWA) features
## Migration Notes
### Breaking Changes
- None - all changes are additive
### Backward Compatibility
- Existing functionality preserved
- All API calls unchanged
- State management compatible
## Testing Recommendations
### Unit Tests
- Component rendering
- User interactions
- State updates
- Error scenarios
### Integration Tests
- Page navigation
- Form submissions
- API interactions
- Modal workflows
### E2E Tests
- Complete user flows
- Multi-step processes
- Cross-page interactions
- Error recovery
## Conclusion
This comprehensive redesign transforms the Primora platform into a modern, user-friendly application with:
- **Better UX**: Intuitive navigation and workflows
- **Improved Maintainability**: Modular component architecture
- **Enhanced Performance**: Optimized rendering and data loading
- **Professional Polish**: Consistent design and interactions
- **Scalability**: Ready for future features and growth
The platform is now production-ready with a solid foundation for continued development and enhancement.
+646
View File
@@ -0,0 +1,646 @@
# Primora Frontend Migration Guide
## Overview
This guide helps you migrate from the previous component patterns to the new enhanced component library.
---
## No Breaking Changes! 🎉
Good news: **All enhancements are backward compatible**. Your existing code will continue to work without modifications. This guide shows you how to leverage the new features.
---
## New Components Available
### 1. Replace Custom Modals with Modal Component
**Before:**
```tsx
<Show when={showDialog()}>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
<div class="bg-surface-1 rounded-lg p-6 max-w-md">
<h2>Confirm Action</h2>
<p>Are you sure?</p>
<div class="flex gap-3 mt-4">
<button onClick={() => setShowDialog(false)}>Cancel</button>
<button onClick={handleConfirm}>Confirm</button>
</div>
</div>
</div>
</Show>
```
**After:**
```tsx
<Modal
open={showDialog()}
onClose={() => setShowDialog(false)}
title="Confirm Action"
description="Are you sure?"
size="md"
>
<ModalFooter align="right">
<Button variant="secondary" onClick={() => setShowDialog(false)}>
Cancel
</Button>
<Button variant="primary" onClick={handleConfirm}>
Confirm
</Button>
</ModalFooter>
</Modal>
```
**Benefits:**
- Automatic backdrop blur
- ESC key support
- Click outside to close
- Proper z-index management
- Smooth animations
- Accessibility built-in
---
### 2. Add Tooltips to Icon Buttons
**Before:**
```tsx
<Button variant="ghost" icon={<Icons.Trash />} />
```
**After:**
```tsx
<Tooltip content="Delete this item" placement="top">
<Button variant="ghost" icon={<Icons.Trash />} />
</Tooltip>
```
**Benefits:**
- Better UX with contextual help
- Smart positioning
- Keyboard accessible
- Configurable delay
---
### 3. Replace Custom Dropdowns with Dropdown Component
**Before:**
```tsx
<Show when={showMenu()}>
<div class="absolute bg-surface-1 rounded-lg shadow-lg">
<button onClick={handleEdit}>Edit</button>
<button onClick={handleDelete}>Delete</button>
</div>
</Show>
```
**After:**
```tsx
<Dropdown
trigger={<Button variant="secondary">Actions</Button>}
items={[
{ id: "edit", label: "Edit", icon: <Icons.Edit />, onClick: handleEdit },
{ id: "divider", divider: true },
{ id: "delete", label: "Delete", icon: <Icons.Trash />, danger: true, onClick: handleDelete },
]}
/>
```
**Benefits:**
- Smart positioning
- Click outside to close
- Keyboard navigation
- Icon support
- Danger states
- Dividers
---
### 4. Use Toast Instead of Custom Alerts
**Before:**
```tsx
<Show when={message()}>
<div class="fixed bottom-4 right-4 bg-success-muted p-4 rounded-lg">
{message()}
</div>
</Show>
```
**After:**
```tsx
// Add once to app root
<ToastContainer />
// Use anywhere
toast.success("Operation successful!");
toast.error("Something went wrong");
toast.info("New update available");
```
**Benefits:**
- Global API
- Auto-dismiss
- Stacked notifications
- Multiple variants
- Smooth animations
---
### 5. Add Loading States with Progress
**Before:**
```tsx
<Show when={loading()}>
<div class="spinner" />
</Show>
```
**After:**
```tsx
// Spinner
<Spinner size="lg" variant="primary" />
// Progress bar
<Progress value={uploadProgress()} showLabel label="Uploading..." />
// Circular progress
<CircularProgress value={60} showLabel />
```
**Benefits:**
- Multiple variants
- Size options
- Label support
- Smooth animations
---
### 6. Use Tabs for Multi-Section Views
**Before:**
```tsx
<div class="flex gap-2 border-b">
<button
class={activeTab() === "overview" ? "border-b-2 border-accent" : ""}
onClick={() => setActiveTab("overview")}
>
Overview
</button>
<button
class={activeTab() === "settings" ? "border-b-2 border-accent" : ""}
onClick={() => setActiveTab("settings")}
>
Settings
</button>
</div>
<Show when={activeTab() === "overview"}>
<OverviewPanel />
</Show>
<Show when={activeTab() === "settings"}>
<SettingsPanel />
</Show>
```
**After:**
```tsx
<Tabs
variant="underline"
defaultTab="overview"
tabs={[
{ id: "overview", label: "Overview", icon: <Icons.Dashboard />, content: <OverviewPanel /> },
{ id: "settings", label: "Settings", badge: "3", content: <SettingsPanel /> },
]}
onChange={(tabId) => console.log(tabId)}
/>
```
**Benefits:**
- Multiple variants (default, pills, underline)
- Icon and badge support
- Disabled states
- Smooth transitions
- Keyboard navigation
---
## Enhanced Components
### Button Enhancements
**New Features:**
```tsx
// Loading state
<Button variant="primary" loading={submitting()}>
Saving...
</Button>
// Icon positioning
<Button icon={<Icons.Plus />} iconPosition="left">
Create
</Button>
// Sizes
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
```
---
### Card Enhancements
**New Features:**
```tsx
// Elevated variant
<Card variant="elevated">
<CardHeader eyebrow="Overview" title="Dashboard" description="Monitor metrics" />
<CardContent>...</CardContent>
<CardFooter align="right">
<Button>Action</Button>
</CardFooter>
</Card>
// Interactive card
<Card variant="interactive" onClick={handleClick}>
Clickable card
</Card>
// Stat card
<StatCard
label="Total Users"
value={1234}
icon={<Icons.Users />}
trend="up"
trendValue="+12%"
/>
// Hover effects
<Card class="card-hover-lift spotlight">
Interactive card with effects
</Card>
```
---
### Input Enhancements
**New Features:**
```tsx
// Error states
<Input
label="Email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
error={errors().email}
/>
// Sizes
<Input size="sm" placeholder="Small input" />
<Input size="md" placeholder="Medium input" />
<Input size="lg" placeholder="Large input" />
```
---
## New CSS Utilities
### Animations
```tsx
// Fade in
<div class="animate-fade-in">Content</div>
// Slide animations
<div class="animate-slide-up">Slide from bottom</div>
<div class="animate-slide-down">Slide from top</div>
<div class="animate-slide-left">Slide from right</div>
<div class="animate-slide-right">Slide from left</div>
// Scale and bounce
<div class="animate-scale-in">Scale in</div>
<div class="animate-bounce-in">Bounce in</div>
// Stagger children
<div class="stagger-fade-in">
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</div>
```
---
### Visual Effects
```tsx
// Card effects
<Card class="card-hover-lift">Lifts on hover</Card>
<Card class="spotlight">Shine effect</Card>
// Glass effect
<div class="glass">Frosted glass background</div>
// Text effects
<span class="text-shimmer">Shimmer text</span>
<span class="neon-glow">Neon glow</span>
<span class="gradient-text">Gradient text</span>
// Loading states
<div class="skeleton h-4 w-32" />
<div class="skeleton-wave h-20 w-full" />
<SkeletonCard lines={3} />
```
---
## Common Migration Patterns
### 1. Confirmation Dialogs
**Before:**
```tsx
const [showConfirm, setShowConfirm] = createSignal(false);
<Show when={showConfirm()}>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
<div class="bg-surface-1 rounded-lg p-6">
<h3>Confirm Deletion</h3>
<p>This action cannot be undone.</p>
<div class="flex gap-3 mt-4">
<button onClick={() => setShowConfirm(false)}>Cancel</button>
<button onClick={handleDelete}>Delete</button>
</div>
</div>
</div>
</Show>
```
**After:**
```tsx
const [showConfirm, setShowConfirm] = createSignal(false);
<Modal
open={showConfirm()}
onClose={() => setShowConfirm(false)}
title="Confirm Deletion"
description="This action cannot be undone."
size="sm"
>
<ModalFooter align="right">
<Button variant="secondary" onClick={() => setShowConfirm(false)}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</Modal>
```
---
### 2. Action Menus
**Before:**
```tsx
<button onClick={() => setShowMenu(!showMenu())}></button>
<Show when={showMenu()}>
<div class="absolute bg-surface-1 rounded-lg shadow-lg">
<button onClick={handleEdit}>Edit</button>
<button onClick={handleDelete}>Delete</button>
</div>
</Show>
```
**After:**
```tsx
<Dropdown
trigger={<Button variant="ghost" size="sm"></Button>}
items={[
{ id: "view", label: "View Details", icon: <Icons.Eye /> },
{ id: "edit", label: "Edit", icon: <Icons.Edit /> },
{ id: "divider", divider: true },
{ id: "delete", label: "Delete", icon: <Icons.Trash />, danger: true },
]}
/>
```
---
### 3. Loading States
**Before:**
```tsx
<Show when={!loading()} fallback={<div class="spinner" />}>
{/* Content */}
</Show>
```
**After:**
```tsx
<Show when={!loading()} fallback={<Spinner size="lg" />}>
{/* Content */}
</Show>
// Or with progress
<Show when={!loading()} fallback={
<div class="flex flex-col items-center gap-4">
<Spinner size="lg" />
<Progress value={progress()} showLabel />
</div>
}>
{/* Content */}
</Show>
```
---
### 4. Form Submissions
**Before:**
```tsx
<form onSubmit={handleSubmit}>
<input
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
<button type="submit" disabled={submitting()}>
{submitting() ? "Saving..." : "Save"}
</button>
</form>
```
**After:**
```tsx
<form onSubmit={handleSubmit} class="space-y-4">
<Input
label="Name"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
error={errors().name}
/>
<Button type="submit" variant="primary" loading={submitting()}>
Save
</Button>
</form>
```
---
### 5. Success/Error Messages
**Before:**
```tsx
<Show when={successMessage()}>
<div class="bg-success-muted text-success p-4 rounded-lg">
{successMessage()}
</div>
</Show>
```
**After:**
```tsx
// Option 1: Message component
<Show when={successMessage()}>
<Message variant="success" dismissible onDismiss={() => setSuccessMessage("")}>
{successMessage()}
</Message>
</Show>
// Option 2: Toast (recommended)
toast.success(successMessage());
```
---
## Step-by-Step Migration
### Phase 1: Add ToastContainer
```tsx
// In your App.tsx or main component
import { ToastContainer } from "./components";
export default function App() {
return (
<>
<ToastContainer />
{/* Rest of your app */}
</>
);
}
```
### Phase 2: Replace Alerts with Toasts
Replace all custom alert/message displays with toast notifications.
### Phase 3: Add Tooltips
Add tooltips to icon-only buttons for better UX.
### Phase 4: Replace Custom Modals
Migrate custom modal implementations to the Modal component.
### Phase 5: Add Dropdowns
Replace custom dropdown menus with the Dropdown component.
### Phase 6: Enhance Forms
Add loading states, error handling, and progress indicators to forms.
### Phase 7: Add Tabs
Replace custom tab implementations with the Tabs component.
---
## Best Practices
### 1. Use Semantic Components
```tsx
// Good
<Button variant="primary" onClick={handleSubmit}>Submit</Button>
// Avoid
<button class="btn btn-primary" onClick={handleSubmit}>Submit</button>
```
### 2. Leverage Loading States
```tsx
// Good
<Button loading={submitting()}>Save</Button>
// Avoid
<Button disabled={submitting()}>
{submitting() ? "Saving..." : "Save"}
</Button>
```
### 3. Use Toast for Notifications
```tsx
// Good
toast.success("Project created!");
// Avoid
setMessage("Project created!");
setTimeout(() => setMessage(""), 3000);
```
### 4. Add Tooltips to Icons
```tsx
// Good
<Tooltip content="Delete">
<Button icon={<Icons.Trash />} />
</Tooltip>
// Avoid
<Button icon={<Icons.Trash />} />
```
### 5. Use Proper Variants
```tsx
// Good
<Button variant="danger" onClick={handleDelete}>Delete</Button>
// Avoid
<Button class="bg-error" onClick={handleDelete}>Delete</Button>
```
---
## Troubleshooting
### Modal Not Showing
Make sure the Modal is rendered and `open` prop is true:
```tsx
<Modal open={show()} onClose={() => setShow(false)}>
```
### Tooltip Not Appearing
Check that the tooltip has a trigger element:
```tsx
<Tooltip content="Help text">
<button>Hover me</button>
</Tooltip>
```
### Toast Not Working
Ensure ToastContainer is added to your app root:
```tsx
<ToastContainer />
```
### Dropdown Not Positioning Correctly
The dropdown uses Portal rendering. Make sure your app has proper z-index management.
---
## Need Help?
- **Quick Reference**: See `COMPONENT_GUIDE.md`
- **Detailed Docs**: See `FRONTEND_ENHANCEMENTS.md`
- **Design System**: See `project_frontend.md`
---
**Happy migrating! 🚀**
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d0d0f" />
<meta name="color-scheme" content="dark" />
<meta name="description" content="Primora — A modern backend platform OS for developers" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Primora — Backend Platform OS</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- Critical CSS for initial render -->
<style>
html { background: #0d0d0f; }
body { opacity: 0; animation: fadeIn 0.2s ease-out forwards; }
@keyframes fadeIn { to { opacity: 1; } }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@primora/frontend",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@primora/api-client": "0.2.0",
"@primora/shared-types": "0.2.0",
"better-auth": "^1.5.6",
"solid-js": "^1.9.12"
},
"devDependencies": {
"@solidjs/router": "^0.15.3",
"@solidjs/testing-library": "^0.8.10",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^6.6.3",
"autoprefixer": "^10.4.21",
"jsdom": "^26.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2",
"vite": "^8.0.3",
"vite-plugin-solid": "^2.11.8",
"vitest": "^3.2.4"
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Primora - Neo-Brutalist Design Showcase</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main-showcase.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+350
View File
@@ -0,0 +1,350 @@
import { For, createSignal } from "solid-js";
import { Button, Card, CardHeader, Badge, Progress, Tabs, StatCard } from "./components";
export function ShowcasePage() {
const [activeTab, setActiveTab] = createSignal("components");
const stats = [
{ label: "Active Users", value: "1,247", trend: "up" as const, trendValue: "+12%" },
{ label: "Storage Used", value: "847 GB", trend: "up" as const, trendValue: "+8%" },
{ label: "API Requests", value: "2.4M", trend: "up" as const, trendValue: "+45%" },
{ label: "Projects", value: "23", trend: "neutral" as const },
];
const tableData = [
{ name: "Authentication Service", status: "active", uptime: "99.9%", requests: "1.2M" },
{ name: "Storage API", status: "active", uptime: "99.8%", requests: "847K" },
{ name: "Database", status: "active", uptime: "100%", requests: "2.1M" },
{ name: "Cache Layer", status: "degraded", uptime: "98.2%", requests: "3.4M" },
];
return (
<div class="min-h-screen bg-[var(--bg-main)] text-[var(--text-primary)] p-8">
<div class="max-w-7xl mx-auto space-y-12">
{/* Hero Section */}
<div class="space-y-4 animate-fade-in">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--accent-muted)] border border-[var(--accent)] text-[var(--accent)] text-sm font-medium">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Design System v1.0
</div>
<h1 class="text-5xl font-bold tracking-tight">
Primora Design System
</h1>
<p class="text-xl text-[var(--text-secondary)] max-w-2xl">
A refined, dark-first UI system built for developers. Clean, accessible, and production-ready.
</p>
</div>
{/* Stats Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-slide-up">
<For each={stats}>
{(stat) => (
<StatCard
label={stat.label}
value={stat.value}
trend={stat.trend}
trendValue={stat.trendValue}
/>
)}
</For>
</div>
{/* Tabs Section */}
<Card>
<Tabs
tabs={[
{ id: "components", label: "Components" },
{ id: "colors", label: "Colors" },
{ id: "typography", label: "Typography" },
]}
activeTab={activeTab()}
onChange={setActiveTab}
/>
<div class="mt-6">
{activeTab() === "components" && (
<div class="space-y-8">
{/* Buttons */}
<div>
<h3 class="text-lg font-semibold mb-4">Buttons</h3>
<div class="flex flex-wrap gap-3">
<Button variant="primary">Primary Button</Button>
<Button variant="secondary">Secondary Button</Button>
<Button variant="ghost">Ghost Button</Button>
<Button variant="danger">Danger Button</Button>
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" size="lg">Large</Button>
<Button variant="primary" loading>Loading...</Button>
</div>
</div>
{/* Badges */}
<div>
<h3 class="text-lg font-semibold mb-4">Badges</h3>
<div class="flex flex-wrap gap-3">
<Badge variant="primary">Primary</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="error">Error</Badge>
<Badge variant="neutral">Neutral</Badge>
</div>
</div>
{/* Progress Bars */}
<div>
<h3 class="text-lg font-semibold mb-4">Progress Indicators</h3>
<div class="space-y-4">
<Progress value={75} showLabel label="CPU Usage" />
<Progress value={60} showLabel label="Memory" />
<Progress value={90} showLabel label="Disk Space" variant="warning" />
</div>
</div>
{/* Table */}
<div>
<h3 class="text-lg font-semibold mb-4">Data Table</h3>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Uptime</th>
<th>Requests</th>
</tr>
</thead>
<tbody>
<For each={tableData}>
{(row) => (
<tr>
<td class="font-medium">{row.name}</td>
<td>
<Badge variant={row.status === "active" ? "success" : "warning"}>
{row.status}
</Badge>
</td>
<td class="text-[var(--text-secondary)]">{row.uptime}</td>
<td class="font-mono text-sm">{row.requests}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab() === "colors" && (
<div class="space-y-6">
<div>
<h3 class="text-lg font-semibold mb-4">Accent Color</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#19a3d9</p>
<p class="text-xs text-[var(--text-muted)]">Primary Accent</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent-hover)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#22b8f0</p>
<p class="text-xs text-[var(--text-muted)]">Hover State</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent-muted)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">rgba(25, 163, 217, 0.08)</p>
<p class="text-xs text-[var(--text-muted)]">Muted</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--accent-subtle)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">rgba(25, 163, 217, 0.12)</p>
<p class="text-xs text-[var(--text-muted)]">Subtle</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Status Colors</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--success)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#22c55e</p>
<p class="text-xs text-[var(--text-muted)]">Success</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--warning)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#f59e0b</p>
<p class="text-xs text-[var(--text-muted)]">Warning</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--error)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#ef4444</p>
<p class="text-xs text-[var(--text-muted)]">Error</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--info)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#3b82f6</p>
<p class="text-xs text-[var(--text-muted)]">Info</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Surface Colors</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--bg-main)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#131315</p>
<p class="text-xs text-[var(--text-muted)]">Background</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--surface-1)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#1d1d21</p>
<p class="text-xs text-[var(--text-muted)]">Surface 1</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--surface-2)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#2d2d31</p>
<p class="text-xs text-[var(--text-muted)]">Surface 2</p>
</div>
<div class="space-y-2">
<div class="h-20 rounded-lg bg-[var(--surface-3)] border border-[var(--border)]" />
<p class="text-sm font-mono text-[var(--text-secondary)]">#4a4a4d</p>
<p class="text-xs text-[var(--text-muted)]">Surface 3</p>
</div>
</div>
</div>
</div>
)}
{activeTab() === "typography" && (
<div class="space-y-8">
<div>
<h3 class="text-lg font-semibold mb-4">Headings</h3>
<div class="space-y-4">
<div>
<h1 class="mb-1">Heading 1</h1>
<p class="text-sm text-[var(--text-muted)] font-mono">2rem / 32px - Bold</p>
</div>
<div>
<h2 class="mb-1">Heading 2</h2>
<p class="text-sm text-[var(--text-muted)] font-mono">1.5rem / 24px - Semibold</p>
</div>
<div>
<h3 class="mb-1">Heading 3</h3>
<p class="text-sm text-[var(--text-muted)] font-mono">1.25rem / 20px - Semibold</p>
</div>
<div>
<h4 class="mb-1">Heading 4</h4>
<p class="text-sm text-[var(--text-muted)] font-mono">1.125rem / 18px - Semibold</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Body Text</h3>
<div class="space-y-4">
<div>
<p class="text-[var(--text-primary)] mb-1">
Primary text color - used for main content and headings
</p>
<p class="text-sm text-[var(--text-muted)] font-mono">var(--text-primary) #ededf0</p>
</div>
<div>
<p class="text-[var(--text-secondary)] mb-1">
Secondary text color - used for supporting content
</p>
<p class="text-sm text-[var(--text-muted)] font-mono">var(--text-secondary) #bebec4</p>
</div>
<div>
<p class="text-[var(--text-muted)] mb-1">
Muted text color - used for labels and metadata
</p>
<p class="text-sm text-[var(--text-muted)] font-mono">var(--text-muted) #5b5b5f</p>
</div>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-4">Code & Monospace</h3>
<div class="space-y-4">
<div>
<p class="mb-2">Inline code: <code>const value = "example";</code></p>
</div>
<div class="bg-[var(--surface-1)] border border-[var(--border)] rounded-lg p-4">
<pre class="font-mono text-sm text-[var(--text-secondary)]">
{`function greet(name: string) {
return \`Hello, \${name}!\`;
}
const message = greet("Primora");
console.log(message);`}
</pre>
</div>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Design Principles */}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<div class="space-y-3">
<div class="w-12 h-12 rounded-lg bg-[var(--accent-muted)] flex items-center justify-center">
<svg class="w-6 h-6 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-lg font-semibold">Fast & Responsive</h3>
<p class="text-sm text-[var(--text-secondary)]">
Built with performance in mind. Smooth transitions and instant feedback.
</p>
</div>
</Card>
<Card>
<div class="space-y-3">
<div class="w-12 h-12 rounded-lg bg-[var(--success-muted)] flex items-center justify-center">
<svg class="w-6 h-6 text-[var(--success)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 class="text-lg font-semibold">Accessible</h3>
<p class="text-sm text-[var(--text-secondary)]">
WCAG AA compliant with full keyboard navigation and screen reader support.
</p>
</div>
</Card>
<Card>
<div class="space-y-3">
<div class="w-12 h-12 rounded-lg bg-[var(--warning-muted)] flex items-center justify-center">
<svg class="w-6 h-6 text-[var(--warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<h3 class="text-lg font-semibold">Customizable</h3>
<p class="text-sm text-[var(--text-secondary)]">
CSS variables make it easy to adapt the design system to your brand.
</p>
</div>
</Card>
</div>
{/* Footer */}
<div class="text-center py-8 border-t border-[var(--border)]">
<p class="text-[var(--text-secondary)]">
Built with SolidJS, TypeScript, and Tailwind CSS
</p>
<p class="text-sm text-[var(--text-muted)] mt-2">
Primora Design System v1.0 - Dark-first, refined, developer-focused
</p>
</div>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { type JSX, splitProps } from "solid-js";
type BadgeVariant = "primary" | "success" | "warning" | "error" | "neutral";
interface BadgeProps extends JSX.HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
const variantClasses: Record<BadgeVariant, string> = {
primary: "badge-primary",
success: "badge-success",
warning: "badge-warning",
error: "badge-error",
neutral: "badge-neutral",
};
export function Badge(props: BadgeProps) {
const [local, rest] = splitProps(props, ["variant", "children", "class"]);
const variant = () => local.variant ?? "neutral";
return (
<span class={`${variantClasses[variant()]} ${local.class ?? ""}`} {...rest}>
{local.children}
</span>
);
}
interface StatusBadgeProps {
status: "pending" | "active" | "completed" | "error" | "expired";
}
const statusMap: Record<StatusBadgeProps["status"], { variant: BadgeVariant; label: string }> = {
pending: { variant: "warning", label: "Pending" },
active: { variant: "primary", label: "Active" },
completed: { variant: "success", label: "Completed" },
error: { variant: "error", label: "Error" },
expired: { variant: "neutral", label: "Expired" },
};
export function StatusBadge(props: StatusBadgeProps) {
const config = () => statusMap[props.status];
return <Badge variant={config().variant}>{config().label}</Badge>;
}
+65
View File
@@ -0,0 +1,65 @@
import { type JSX, Show, splitProps } from "solid-js";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "outline";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: JSX.Element;
iconPosition?: "left" | "right";
}
const variantClasses: Record<ButtonVariant, string> = {
primary: "btn-primary",
secondary: "btn-secondary",
ghost: "btn-ghost",
danger: "btn-danger",
outline: "btn-secondary border border-gray-300",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "btn-sm",
md: "",
lg: "btn-lg",
};
export function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, [
"variant",
"size",
"loading",
"icon",
"iconPosition",
"children",
"class",
"disabled",
]);
const variant = () => local.variant ?? "secondary";
const size = () => local.size ?? "md";
const iconPosition = () => local.iconPosition ?? "left";
return (
<button
class={`btn ${variantClasses[variant()]} ${sizeClasses[size()]} ${local.class ?? ""}`}
disabled={local.disabled ?? local.loading}
aria-busy={local.loading}
{...rest}
>
<Show when={local.loading}>
<span class="spinner" aria-hidden="true" />
</Show>
<Show when={local.icon && !local.loading && iconPosition() === "left"}>
<span class="shrink-0 flex items-center">{local.icon}</span>
</Show>
<Show when={local.children}>
<span class={local.loading ? "opacity-0" : ""}>{local.children}</span>
</Show>
<Show when={local.icon && !local.loading && iconPosition() === "right"}>
<span class="shrink-0 flex items-center">{local.icon}</span>
</Show>
</button>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { type JSX, Show, splitProps } from "solid-js";
interface CardProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "elevated" | "interactive";
padding?: "none" | "sm" | "md" | "lg";
hoverable?: boolean;
}
const variantClasses = {
default: "card",
elevated: "card-elevated",
interactive: "card-interactive",
};
const paddingClasses = {
none: "!p-0",
sm: "!p-3",
md: "",
lg: "!p-6",
};
export function Card(props: CardProps) {
const [local, rest] = splitProps(props, ["variant", "padding", "hoverable", "children", "class"]);
const variant = () => local.variant ?? "default";
const padding = () => local.padding ?? "md";
return (
<div
class={`${variantClasses[variant()]} ${paddingClasses[padding()]} ${local.hoverable ? "card-interactive" : ""} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
interface CardHeaderProps extends JSX.HTMLAttributes<HTMLDivElement> {
eyebrow?: string;
title: string;
description?: string;
}
export function CardHeader(props: CardHeaderProps) {
const [local, rest] = splitProps(props, ["eyebrow", "title", "description", "class"]);
return (
<div class={`card-header ${local.class ?? ""}`} {...rest}>
<Show when={local.eyebrow}>
<p class="text-xs font-semibold text-accent uppercase tracking-wider mb-1">{local.eyebrow}</p>
</Show>
<h3 class="card-header-title">{local.title}</h3>
<Show when={local.description}>
<p class="card-header-description">{local.description}</p>
</Show>
</div>
);
}
interface CardContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Apply subtle background tint */
tint?: boolean;
}
export function CardContent(props: CardContentProps) {
const [local, rest] = splitProps(props, ["tint", "children", "class"]);
return (
<div
class={`space-y-4 ${local.tint ? "bg-surface-2/30 -mx-4 -mb-4 px-4 pb-4 pt-4 rounded-b-lg" : ""} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
interface CardFooterProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Align actions to the right */
align?: "left" | "right" | "between";
}
export function CardFooter(props: CardFooterProps) {
const [local, rest] = splitProps(props, ["align", "children", "class"]);
const alignClass = () => {
switch (local.align) {
case "right":
return "justify-end";
case "between":
return "justify-between";
default:
return "justify-start";
}
};
return (
<div
class={`flex items-center gap-3 pt-4 mt-4 border-t border-border ${alignClass()} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
interface StatCardProps {
label: string;
value: string | number;
icon?: JSX.Element;
trend?: "up" | "down" | "neutral";
trendValue?: string;
description?: string;
}
export function StatCard(props: StatCardProps) {
return (
<div class="stat-card group">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="stat-label">{props.label}</p>
<p class="stat-value">{props.value}</p>
<Show when={props.description}>
<p class="text-xs text-text-muted mt-1.5 leading-relaxed">{props.description}</p>
</Show>
</div>
<Show when={props.icon}>
<div class="text-text-muted opacity-40 group-hover:opacity-60 transition-opacity">{props.icon}</div>
</Show>
</div>
<Show when={props.trend && props.trendValue}>
<div
class={`mt-3 text-xs flex items-center gap-1.5 font-medium ${
props.trend === "up"
? "text-success"
: props.trend === "down"
? "text-error"
: "text-text-muted"
}`}
>
<Show when={props.trend === "up"}>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</Show>
<Show when={props.trend === "down"}>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</Show>
{props.trendValue}
</div>
</Show>
</div>
);
}
@@ -0,0 +1,251 @@
import { type JSX, For, Show, createSignal, createEffect, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
interface Command {
id: string;
label: string;
description?: string;
icon?: JSX.Element;
keywords?: string[];
shortcut?: string;
onExecute: () => void;
category?: string;
}
interface CommandPaletteProps {
commands: Command[];
placeholder?: string;
shortcut?: string;
}
export function CommandPalette(props: CommandPaletteProps) {
const [open, setOpen] = createSignal(false);
const [search, setSearch] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
let inputRef: HTMLInputElement | undefined;
const shortcut = () => props.shortcut ?? "k";
// Filter commands based on search
const filteredCommands = () => {
const query = search().toLowerCase();
if (!query) return props.commands;
return props.commands.filter((cmd) => {
const searchText = [
cmd.label,
cmd.description,
...(cmd.keywords || []),
].join(" ").toLowerCase();
return searchText.includes(query);
});
};
// Group commands by category
const groupedCommands = () => {
const commands = filteredCommands();
const groups = new Map<string, Command[]>();
commands.forEach((cmd) => {
const category = cmd.category || "Commands";
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(cmd);
});
return Array.from(groups.entries());
};
// Keyboard shortcuts
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Open with Cmd/Ctrl + K
if ((e.metaKey || e.ctrlKey) && e.key === shortcut()) {
e.preventDefault();
setOpen(true);
return;
}
if (!open()) return;
// Close with Escape
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
setSearch("");
setSelectedIndex(0);
return;
}
// Navigate with arrows
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filteredCommands().length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
}
// Execute with Enter
if (e.key === "Enter") {
e.preventDefault();
const commands = filteredCommands();
if (commands[selectedIndex()]) {
executeCommand(commands[selectedIndex()]);
}
}
};
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
});
// Focus input when opened
createEffect(() => {
if (open() && inputRef) {
requestAnimationFrame(() => inputRef?.focus());
}
});
// Reset selection when search changes
createEffect(() => {
search();
setSelectedIndex(0);
});
const executeCommand = (command: Command) => {
command.onExecute();
setOpen(false);
setSearch("");
setSelectedIndex(0);
};
return (
<>
{/* Trigger button */}
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-text-secondary bg-surface-1 border border-border rounded-lg hover:border-border-hover transition-colors"
onClick={() => setOpen(true)}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>{props.placeholder ?? "Search..."}</span>
<kbd class="ml-auto px-1.5 py-0.5 text-xs bg-surface-2 border border-border rounded">
{shortcut().toUpperCase()}
</kbd>
</button>
{/* Command palette modal */}
<Show when={open()}>
<Portal>
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] animate-fade-in"
onClick={() => setOpen(false)}
>
{/* Backdrop */}
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Palette */}
<div
class="relative w-full max-w-2xl bg-surface-1 border border-border-strong rounded-xl shadow-elevated animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Search input */}
<div class="flex items-center gap-3 px-4 py-3 border-b border-border">
<svg class="h-5 w-5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
type="text"
class="flex-1 bg-transparent text-text-primary placeholder-text-muted outline-none"
placeholder={props.placeholder ?? "Type a command or search..."}
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
/>
<kbd class="px-2 py-1 text-xs text-text-muted bg-surface-2 border border-border rounded">
ESC
</kbd>
</div>
{/* Commands list */}
<div class="max-h-[400px] overflow-y-auto py-2">
<Show
when={filteredCommands().length > 0}
fallback={
<div class="px-4 py-8 text-center text-text-muted">
<p>No commands found</p>
</div>
}
>
<For each={groupedCommands()}>
{([category, commands]) => (
<div class="mb-2">
<div class="px-4 py-1.5 text-xs font-semibold text-text-muted uppercase tracking-wider">
{category}
</div>
<For each={commands}>
{(command, index) => {
const globalIndex = filteredCommands().indexOf(command);
const isSelected = () => selectedIndex() === globalIndex;
return (
<button
class={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected()
? "bg-accent-subtle text-accent"
: "text-text-primary hover:bg-surface-2"
}`}
onClick={() => executeCommand(command)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<Show when={command.icon}>
<span class="flex-shrink-0">{command.icon}</span>
</Show>
<div class="flex-1 min-w-0">
<div class="font-medium">{command.label}</div>
<Show when={command.description}>
<div class="text-xs text-text-muted truncate">
{command.description}
</div>
</Show>
</div>
<Show when={command.shortcut}>
<kbd class="px-2 py-1 text-xs bg-surface-2 border border-border rounded">
{command.shortcut}
</kbd>
</Show>
</button>
);
}}
</For>
</div>
)}
</For>
</Show>
</div>
{/* Footer */}
<div class="flex items-center justify-between px-4 py-2 border-t border-border text-xs text-text-muted">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-surface-2 border border-border rounded"></kbd>
<kbd class="px-1.5 py-0.5 bg-surface-2 border border-border rounded"></kbd>
to navigate
</span>
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-surface-2 border border-border rounded"></kbd>
to select
</span>
</div>
<span>{filteredCommands().length} commands</span>
</div>
</div>
</div>
</Portal>
</Show>
</>
);
}
@@ -0,0 +1,198 @@
import { Show, For, createSignal, createEffect, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
interface Command {
id: string;
label: string;
description?: string;
icon?: any;
keywords?: string[];
action: () => void;
category?: string;
}
interface CommandPaletteEnhancedProps {
commands: Command[];
isOpen: boolean;
onClose: () => void;
}
export function CommandPaletteEnhanced(props: CommandPaletteEnhancedProps) {
const [search, setSearch] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredCommands = () => {
const query = search().toLowerCase();
if (!query) return props.commands;
return props.commands.filter(cmd => {
const searchText = `${cmd.label} ${cmd.description || ""} ${cmd.keywords?.join(" ") || ""}`.toLowerCase();
return searchText.includes(query);
});
};
const groupedCommands = () => {
const commands = filteredCommands();
const groups: Record<string, Command[]> = {};
commands.forEach(cmd => {
const category = cmd.category || "General";
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(cmd);
});
return groups;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!props.isOpen) return;
const commands = filteredCommands();
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, commands.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (commands[selectedIndex()]) {
commands[selectedIndex()].action();
props.onClose();
}
break;
case "Escape":
e.preventDefault();
props.onClose();
break;
}
};
createEffect(() => {
if (props.isOpen) {
document.addEventListener("keydown", handleKeyDown);
setSearch("");
setSelectedIndex(0);
}
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
});
});
return (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4 animate-fade-in">
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={props.onClose}
/>
{/* Command Palette */}
<div class="relative w-full max-w-2xl bg-white rounded-xl shadow-2xl animate-scale-in">
{/* Search Input */}
<div class="p-4 border-b border-gray-200">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search commands..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="flex-1 bg-transparent border-none outline-none text-lg"
autofocus
/>
<kbd class="px-2 py-1 text-xs bg-gray-100 rounded border border-gray-300">ESC</kbd>
</div>
</div>
{/* Commands List */}
<div class="max-h-96 overflow-y-auto">
<Show
when={Object.keys(groupedCommands()).length > 0}
fallback={
<div class="p-8 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>No commands found</p>
</div>
}
>
<For each={Object.entries(groupedCommands())}>
{([category, commands]) => (
<div>
<div class="px-4 py-2 text-xs font-semibold text-gray-500 uppercase bg-gray-50">
{category}
</div>
<For each={commands}>
{(command, index) => {
const globalIndex = filteredCommands().indexOf(command);
return (
<button
class={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${
selectedIndex() === globalIndex
? "bg-blue-50 border-l-2 border-blue-500"
: "hover:bg-gray-50"
}`}
onClick={() => {
command.action();
props.onClose();
}}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<Show when={command.icon}>
<div class="flex-shrink-0 w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg">
{command.icon}
</div>
</Show>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900">{command.label}</div>
<Show when={command.description}>
<div class="text-sm text-gray-600 truncate">{command.description}</div>
</Show>
</div>
<Show when={selectedIndex() === globalIndex}>
<kbd class="px-2 py-1 text-xs bg-gray-100 rounded border border-gray-300"></kbd>
</Show>
</button>
);
}}
</For>
</div>
)}
</For>
</Show>
</div>
{/* Footer */}
<div class="p-3 border-t border-gray-200 bg-gray-50 flex items-center justify-between text-xs text-gray-600">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-white rounded border border-gray-300"></kbd>
<kbd class="px-1.5 py-0.5 bg-white rounded border border-gray-300"></kbd>
Navigate
</span>
<span class="flex items-center gap-1">
<kbd class="px-1.5 py-0.5 bg-white rounded border border-gray-300"></kbd>
Select
</span>
</div>
<span>{filteredCommands().length} commands</span>
</div>
</div>
</div>
</Portal>
</Show>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { Show, For, createSignal, createMemo } from "solid-js";
import { Button, Input, Badge } from "./index";
export interface Column<T> {
key: keyof T | string;
label: string;
sortable?: boolean;
filterable?: boolean;
render?: (value: any, row: T) => any;
width?: string;
align?: "left" | "center" | "right";
}
export interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
keyField: keyof T;
onRowClick?: (row: T) => void;
loading?: boolean;
emptyMessage?: string;
pageSize?: number;
searchable?: boolean;
exportable?: boolean;
onExport?: (data: T[]) => void;
}
export function DataTable<T extends Record<string, any>>(props: DataTableProps<T>) {
const [sortKey, setSortKey] = createSignal<keyof T | string | null>(null);
const [sortDirection, setSortDirection] = createSignal<"asc" | "desc">("asc");
const [searchQuery, setSearchQuery] = createSignal("");
const [currentPage, setCurrentPage] = createSignal(1);
const [filters, setFilters] = createSignal<Record<string, string>>({});
const pageSize = () => props.pageSize || 10;
// Filter data
const filteredData = createMemo(() => {
let result = [...props.data];
// Apply search
if (searchQuery().trim()) {
const query = searchQuery().toLowerCase();
result = result.filter(row => {
return props.columns.some(col => {
const value = row[col.key as keyof T];
return String(value).toLowerCase().includes(query);
});
});
}
// Apply column filters
const activeFilters = filters();
Object.entries(activeFilters).forEach(([key, value]) => {
if (value.trim()) {
result = result.filter(row => {
const cellValue = row[key as keyof T];
return String(cellValue).toLowerCase().includes(value.toLowerCase());
});
}
});
return result;
});
// Sort data
const sortedData = createMemo(() => {
const key = sortKey();
if (!key) return filteredData();
return [...filteredData()].sort((a, b) => {
const aVal = a[key as keyof T];
const bVal = b[key as keyof T];
let comparison = 0;
if (aVal < bVal) comparison = -1;
if (aVal > bVal) comparison = 1;
return sortDirection() === "asc" ? comparison : -comparison;
});
});
// Paginate data
const paginatedData = createMemo(() => {
const start = (currentPage() - 1) * pageSize();
const end = start + pageSize();
return sortedData().slice(start, end);
});
const totalPages = createMemo(() => Math.ceil(sortedData().length / pageSize()));
const handleSort = (key: keyof T | string) => {
if (sortKey() === key) {
setSortDirection(d => d === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDirection("asc");
}
};
const handleFilterChange = (key: string, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1);
};
const getSortIcon = (key: keyof T | string) => {
if (sortKey() !== key) {
return (
<svg class="w-4 h-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
return sortDirection() === "asc" ? (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
) : (
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
);
};
return (
<div class="space-y-4">
{/* Toolbar */}
<div class="flex items-center justify-between gap-4">
<Show when={props.searchable}>
<Input
placeholder="Search..."
value={searchQuery()}
onInput={(e) => {
setSearchQuery(e.currentTarget.value);
setCurrentPage(1);
}}
class="max-w-sm"
/>
</Show>
<div class="flex items-center gap-2">
<Badge variant="secondary">
{sortedData().length} {sortedData().length === 1 ? "row" : "rows"}
</Badge>
<Show when={props.exportable && props.onExport}>
<Button
variant="outline"
size="sm"
onClick={() => props.onExport?.(sortedData())}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export
</Button>
</Show>
</div>
</div>
{/* Table */}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<For each={props.columns}>
{(column) => (
<th
class={`px-4 py-3 text-${column.align || "left"} text-xs font-semibold text-gray-700 uppercase tracking-wider ${
column.sortable ? "cursor-pointer hover:bg-gray-100" : ""
}`}
style={column.width ? { width: column.width } : {}}
onClick={() => column.sortable && handleSort(column.key)}
>
<div class="flex items-center gap-2">
<span>{column.label}</span>
<Show when={column.sortable}>
{getSortIcon(column.key)}
</Show>
</div>
</th>
)}
</For>
</tr>
{/* Filter row */}
<Show when={props.columns.some(col => col.filterable)}>
<tr class="bg-gray-50">
<For each={props.columns}>
{(column) => (
<th class="px-4 py-2">
<Show when={column.filterable}>
<Input
placeholder={`Filter ${column.label}...`}
value={filters()[column.key as string] || ""}
onInput={(e) => handleFilterChange(column.key as string, e.currentTarget.value)}
class="text-sm"
size="sm"
/>
</Show>
</th>
)}
</For>
</tr>
</Show>
</thead>
<tbody class="divide-y divide-gray-200">
<Show
when={!props.loading && paginatedData().length > 0}
fallback={
<tr>
<td colspan={props.columns.length} class="px-4 py-12 text-center text-gray-500">
<Show when={props.loading} fallback={props.emptyMessage || "No data available"}>
<div class="flex items-center justify-center gap-2">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
<span>Loading...</span>
</div>
</Show>
</td>
</tr>
}
>
<For each={paginatedData()}>
{(row) => (
<tr
class={`hover:bg-gray-50 transition-colors ${props.onRowClick ? "cursor-pointer" : ""}`}
onClick={() => props.onRowClick?.(row)}
>
<For each={props.columns}>
{(column) => (
<td class={`px-4 py-3 text-${column.align || "left"} text-sm text-gray-900`}>
{column.render
? column.render(row[column.key as keyof T], row)
: String(row[column.key as keyof T] ?? "")}
</td>
)}
</For>
</tr>
)}
</For>
</Show>
</tbody>
</table>
</div>
{/* Pagination */}
<Show when={totalPages() > 1}>
<div class="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-700">
Showing {(currentPage() - 1) * pageSize() + 1} to{" "}
{Math.min(currentPage() * pageSize(), sortedData().length)} of {sortedData().length}
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage() === 1}
onClick={() => setCurrentPage(p => p - 1)}
>
Previous
</Button>
<div class="flex items-center gap-1">
<For each={Array.from({ length: Math.min(5, totalPages()) }, (_, i) => {
const page = i + 1;
if (totalPages() <= 5) return page;
if (currentPage() <= 3) return page;
if (currentPage() >= totalPages() - 2) return totalPages() - 4 + i;
return currentPage() - 2 + i;
})}>
{(page) => (
<button
class={`px-3 py-1 text-sm rounded ${
currentPage() === page
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => setCurrentPage(page)}
>
{page}
</button>
)}
</For>
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage() === totalPages()}
onClick={() => setCurrentPage(p => p + 1)}
>
Next
</Button>
</div>
</div>
</Show>
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { type JSX, Show, For, createSignal, createEffect, onCleanup, splitProps } from "solid-js";
import { Portal } from "solid-js/web";
interface DropdownItem {
id: string;
label: string;
icon?: JSX.Element;
disabled?: boolean;
danger?: boolean;
divider?: boolean;
onClick?: () => void;
}
interface DropdownProps {
items: DropdownItem[];
trigger: JSX.Element;
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
closeOnSelect?: boolean;
}
export function Dropdown(props: DropdownProps) {
const [local] = splitProps(props, ["items", "trigger", "placement", "closeOnSelect"]);
const [open, setOpen] = createSignal(false);
const [position, setPosition] = createSignal({ x: 0, y: 0 });
let triggerRef: HTMLDivElement | undefined;
let menuRef: HTMLDivElement | undefined;
const placement = () => local.placement ?? "bottom-start";
const closeOnSelect = () => local.closeOnSelect ?? true;
const calculatePosition = () => {
if (!triggerRef || !menuRef) return;
const triggerRect = triggerRef.getBoundingClientRect();
const menuRect = menuRef.getBoundingClientRect();
const gap = 4;
let x = 0;
let y = 0;
switch (placement()) {
case "bottom-start":
x = triggerRect.left;
y = triggerRect.bottom + gap;
break;
case "bottom-end":
x = triggerRect.right - menuRect.width;
y = triggerRect.bottom + gap;
break;
case "top-start":
x = triggerRect.left;
y = triggerRect.top - menuRect.height - gap;
break;
case "top-end":
x = triggerRect.right - menuRect.width;
y = triggerRect.top - menuRect.height - gap;
break;
}
// Keep menu within viewport
x = Math.max(8, Math.min(x, window.innerWidth - menuRect.width - 8));
y = Math.max(8, Math.min(y, window.innerHeight - menuRect.height - 8));
setPosition({ x, y });
};
createEffect(() => {
if (open()) {
requestAnimationFrame(calculatePosition);
const handleClickOutside = (e: MouseEvent) => {
if (
triggerRef &&
menuRef &&
!triggerRef.contains(e.target as Node) &&
!menuRef.contains(e.target as Node)
) {
setOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
});
}
});
const handleItemClick = (item: DropdownItem) => {
if (item.disabled) return;
item.onClick?.();
if (closeOnSelect()) {
setOpen(false);
}
};
return (
<>
<div
ref={triggerRef}
onClick={() => setOpen(!open())}
class="inline-block"
>
{local.trigger}
</div>
<Show when={open()}>
<Portal>
<div
ref={menuRef}
class="fixed z-50 min-w-[200px] bg-surface-1 border border-border-strong rounded-lg shadow-elevated py-1 animate-scale-in"
style={{
left: `${position().x}px`,
top: `${position().y}px`,
}}
role="menu"
>
<For each={local.items}>
{(item) => (
<Show
when={!item.divider}
fallback={<div class="my-1 h-px bg-border" role="separator" />}
>
<button
class={`w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors ${
item.disabled
? "opacity-50 cursor-not-allowed"
: item.danger
? "text-error hover:bg-error-muted"
: "text-text-primary hover:bg-surface-2"
}`}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
role="menuitem"
>
<Show when={item.icon}>
<span class="flex-shrink-0">{item.icon}</span>
</Show>
<span class="flex-1">{item.label}</span>
</button>
</Show>
)}
</For>
</div>
</Portal>
</Show>
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { type JSX, For, Show, splitProps } from "solid-js";
interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "input-sm",
md: "",
lg: "input-lg",
};
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["label", "error", "size", "class"]);
const size = () => local.size ?? "md";
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<input
class={`input ${sizeClasses[size()]} ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest}
/>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export function Textarea(props: TextareaProps) {
const [local, rest] = splitProps(props, ["label", "error", "class"]);
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<textarea
class={`textarea ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest}
/>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
interface SelectProps extends JSX.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options?: { value: string; label: string; disabled?: boolean }[];
}
export function Select(props: SelectProps) {
const [local, rest] = splitProps(props, ["label", "error", "options", "children", "class"]);
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<select
class={`select ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest}
>
<Show when={local.options}>
<For each={local.options}>
{(option) => (
<option value={option.value} disabled={option.disabled}>
{option.label}
</option>
)}
</For>
</Show>
{local.children}
</select>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
interface FileInputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function FileInput(props: FileInputProps) {
const [local, rest] = splitProps(props, ["label", "error", "class"]);
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
</Show>
<input
type="file"
class={`input cursor-pointer file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-surface-2 file:text-text-secondary hover:file:bg-surface-3 ${local.error ? "border-error" : ""} ${local.class ?? ""}`}
{...rest}
/>
<Show when={local.error}>
<p class="mt-1 text-xs text-error">{local.error}</p>
</Show>
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { type JSX, Show, createSignal, For } from "solid-js";
interface NavItem {
id: string;
label: string;
icon?: JSX.Element;
badge?: string | number;
children?: NavItem[];
}
interface SidebarProps {
items: NavItem[];
activeId?: string;
onSelect?: (id: string) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
header?: JSX.Element;
footer?: JSX.Element;
}
function SidebarItem(props: { item: NavItem; activeId?: string; onSelect?: (id: string) => void; collapsed?: boolean }) {
const isActive = () => props.item.id === props.activeId;
const hasChildren = () => (props.item.children?.length ?? 0) > 0;
const [expanded, setExpanded] = createSignal(false);
return (
<div>
<button
class={`w-full text-left ${isActive() ? "sidebar-item-active" : "sidebar-item"}`}
onClick={() => {
if (hasChildren()) {
setExpanded(!expanded());
} else {
props.onSelect?.(props.item.id);
}
}}
aria-expanded={hasChildren() ? expanded() : undefined}
aria-current={isActive() ? "page" : undefined}
>
<Show when={props.item.icon}>
<span class="flex-shrink-0">{props.item.icon}</span>
</Show>
<Show when={!props.collapsed}>
<span class="flex-1 truncate">{props.item.label}</span>
</Show>
<Show when={props.item.badge && !props.collapsed}>
<span class="badge-neutral text-2xs">{props.item.badge}</span>
</Show>
<Show when={hasChildren() && !props.collapsed}>
<svg
class={`h-4 w-4 transition-transform ${expanded() ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</Show>
</button>
<Show when={hasChildren() && expanded() && !props.collapsed}>
<div class="ml-4 mt-1 space-y-1">
<For each={props.item.children}>
{(child) => (
<button
class={`w-full text-left sidebar-item text-xs ${child.id === props.activeId ? "sidebar-item-active" : ""}`}
onClick={() => props.onSelect?.(child.id)}
>
{child.label}
</button>
)}
</For>
</div>
</Show>
</div>
);
}
export function Sidebar(props: SidebarProps) {
return (
<aside
class={`sidebar transition-all duration-fast ${props.collapsed ? "w-16" : "w-60"}`}
role="navigation"
aria-label="Main navigation"
>
<Show when={props.header}>
<div class="border-b border-border p-4 flex items-center justify-between">
{props.header}
</div>
</Show>
<nav class="sidebar-nav">
<div class="space-y-1">
<For each={props.items}>
{(item) => (
<SidebarItem
item={item}
activeId={props.activeId}
onSelect={props.onSelect}
collapsed={props.collapsed}
/>
)}
</For>
</div>
</nav>
<Show when={props.footer}>
<div class="border-t border-border p-4">{props.footer}</div>
</Show>
</aside>
);
}
interface HeaderProps {
title?: string;
subtitle?: string;
actions?: JSX.Element;
breadcrumbs?: { label: string; href?: string }[];
onMenuToggle?: () => void;
logo?: JSX.Element;
tabs?: { id: string; label: string }[];
activeTab?: string;
onTabChange?: (id: string) => void;
}
export function Header(props: HeaderProps) {
return (
<header class="top-nav">
<div class="top-nav-main">
<div class="flex items-center gap-6">
<Show when={props.logo}>
{props.logo}
</Show>
<Show when={props.breadcrumbs}>
<nav class="hidden items-center gap-2 text-sm sm:flex" aria-label="Breadcrumb">
<For each={props.breadcrumbs}>
{(crumb, index) => (
<>
<Show when={index() > 0}>
<span class="text-text-muted">/</span>
</Show>
<Show when={crumb.href} fallback={<span class="text-text-primary font-medium">{crumb.label}</span>}>
<a href={crumb.href} class="text-text-secondary hover:text-text-primary transition-colors">
{crumb.label}
</a>
</Show>
</>
)}
</For>
</nav>
</Show>
<Show when={props.title && !props.breadcrumbs}>
<div>
<h1 class="text-lg font-semibold text-text-primary">{props.title}</h1>
<Show when={props.subtitle}>
<p class="text-xs text-text-secondary">{props.subtitle}</p>
</Show>
</div>
</Show>
</div>
<Show when={props.actions}>
<div class="flex items-center gap-3">{props.actions}</div>
</Show>
</div>
<Show when={props.tabs && props.tabs.length > 0}>
<div class="top-nav-tabs">
<For each={props.tabs}>
{(tab) => (
<button
class={`top-nav-tab ${props.activeTab === tab.id ? 'top-nav-tab-active' : ''}`}
onClick={() => props.onTabChange?.(tab.id)}
>
{tab.label}
</button>
)}
</For>
</div>
</Show>
</header>
);
}
interface LayoutProps {
children: JSX.Element;
sidebar?: JSX.Element;
header?: JSX.Element;
sidebarCollapsed?: boolean;
}
export function Layout(props: LayoutProps) {
return (
<div class="flex h-screen flex-col overflow-hidden bg-bg-main">
<Show when={props.header}>
{props.header}
</Show>
<div class="flex flex-1 overflow-hidden">
<Show when={props.sidebar}>
{props.sidebar}
</Show>
<main class="main-content">{props.children}</main>
</div>
</div>
);
}
interface PageHeaderProps {
eyebrow?: string;
title: string;
description?: string;
actions?: JSX.Element;
}
export function PageHeader(props: PageHeaderProps) {
return (
<div class="mb-6 flex flex-col gap-3 md:flex-row md:items-start md:justify-between animate-fade-in">
<div class="space-y-1">
<Show when={props.eyebrow}>
<p class="text-xs font-semibold text-accent uppercase tracking-wider">{props.eyebrow}</p>
</Show>
<h1 class="text-2xl font-bold text-text-primary tracking-tight">{props.title}</h1>
<Show when={props.description}>
<p class="text-sm text-text-secondary max-w-2xl">{props.description}</p>
</Show>
</div>
<Show when={props.actions}>
<div class="flex flex-wrap gap-2">{props.actions}</div>
</Show>
</div>
);
}
interface EmptyStateProps {
icon?: JSX.Element;
title: string;
description?: string;
action?: JSX.Element;
}
export function EmptyState(props: EmptyStateProps) {
return (
<div class="flex flex-col items-center justify-center py-12 px-4 text-center animate-fade-in">
<Show when={props.icon}>
<div class="mb-4 text-text-muted opacity-40">{props.icon}</div>
</Show>
<h3 class="text-base font-semibold text-text-primary">{props.title}</h3>
<Show when={props.description}>
<p class="mt-1 text-sm text-text-secondary max-w-md">{props.description}</p>
</Show>
<Show when={props.action}>
<div class="mt-6">{props.action}</div>
</Show>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { type JSX } from "solid-js";
interface LogoProps {
size?: "sm" | "md" | "lg";
showText?: boolean;
class?: string;
animated?: boolean;
}
const sizeClasses = {
sm: "h-7 w-7 text-xs",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
};
const textSizeClasses = {
sm: "text-sm",
md: "text-lg",
lg: "text-xl",
};
export function Logo(props: LogoProps) {
const size = () => props.size ?? "md";
const showText = () => props.showText ?? true;
const animated = () => props.animated ?? true;
return (
<div class={`flex items-center gap-3 group ${props.class ?? ""}`}>
<div
class={`${sizeClasses[size()]} relative flex items-center justify-center rounded-xl bg-gradient-to-br from-accent via-accent-hover to-accent font-bold text-white shadow-lg ${animated() ? 'transition-all duration-300 group-hover:scale-110 group-hover:rotate-3 group-hover:shadow-xl' : ''}`}
style={{
"box-shadow": "0 4px 20px rgba(25, 163, 217, 0.3), 0 0 40px rgba(25, 163, 217, 0.15)",
}}
>
<span class="relative z-10 font-display font-extrabold">P</span>
{/* Animated glow effect */}
{animated() && (
<div
class="absolute inset-0 rounded-xl bg-gradient-to-br from-accent-hover to-accent opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-sm"
style={{ "z-index": "-1" }}
/>
)}
</div>
{showText() && (
<div class="flex flex-col leading-none">
<span
class={`${textSizeClasses[size()]} font-display font-bold tracking-tight bg-gradient-to-r from-text-primary via-accent to-text-primary bg-clip-text text-transparent ${animated() ? 'transition-all duration-300 group-hover:tracking-wide' : ''}`}
style={{
"background-size": "200% auto",
"animation": animated() ? "shimmer 3s linear infinite" : "none",
}}
>
PRIMORA
</span>
<span class="text-2xs text-text-muted font-medium tracking-widest uppercase mt-0.5">
Platform
</span>
</div>
)}
</div>
);
}
export function LogoIcon(props: { class?: string; animated?: boolean }) {
const animated = () => props.animated ?? false;
return (
<svg
class={`${props.class ?? ''} ${animated() ? 'transition-transform duration-300 hover:scale-110 hover:rotate-6' : ''}`}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id="logo-gradient"
x1="0"
y1="0"
x2="32"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#19a3d9" />
<stop offset="0.5" stop-color="#22b8f0" />
<stop offset="1" stop-color="#19a3d9" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="32" height="32" rx="8" fill="url(#logo-gradient)" filter="url(#glow)" />
{/* P letter with modern design */}
<path
d="M10 9h7c3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7h-3v-5h3c1.657 0 3-1.343 3-3s-1.343-3-3-3h-5v14H10V9z"
fill="white"
opacity="0.95"
/>
{/* Accent dot */}
<circle cx="24" cy="24" r="2.5" fill="white" opacity="0.9">
{animated() && (
<animate
attributeName="opacity"
values="0.9;0.5;0.9"
dur="2s"
repeatCount="indefinite"
/>
)}
</circle>
</svg>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { type JSX, For, Show, splitProps } from "solid-js";
type MessageVariant = "info" | "success" | "warning" | "error" | "neutral";
interface MessageProps extends JSX.HTMLAttributes<HTMLDivElement> {
variant?: MessageVariant;
title?: string;
icon?: JSX.Element;
dismissible?: boolean;
onDismiss?: () => void;
}
const variantClasses: Record<MessageVariant, string> = {
info: "message-info",
success: "message-success",
warning: "message-warning",
error: "message-error",
neutral: "message-neutral",
};
export function Message(props: MessageProps) {
const [local, rest] = splitProps(props, [
"variant",
"title",
"icon",
"dismissible",
"onDismiss",
"children",
"class",
]);
const variant = () => local.variant ?? "neutral";
return (
<div
class={`${variantClasses[variant()]} ${local.class ?? ""}`}
role="alert"
{...rest}
>
<div class="flex gap-3">
<Show when={local.icon}>
<div class="flex-shrink-0">{local.icon}</div>
</Show>
<div class="flex-1">
<Show when={local.title}>
<p class="font-medium">{local.title}</p>
</Show>
<div class={local.title ? "mt-1" : ""}>{local.children}</div>
</div>
<Show when={local.dismissible}>
<button
class="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
onClick={local.onDismiss}
aria-label="Dismiss"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
);
}
interface LoadingProps {
text?: string;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
};
export function Loading(props: LoadingProps) {
const size = () => props.size ?? "md";
return (
<div class="flex items-center gap-3 text-text-secondary animate-fade-in">
<div class={`${sizeClasses[size()]} spinner`} />
<Show when={props.text}>
<span class="text-sm font-medium">{props.text}</span>
</Show>
</div>
);
}
interface SkeletonProps {
class?: string;
width?: string;
height?: string;
rounded?: "sm" | "md" | "lg" | "full";
}
const roundedClasses = {
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
full: "rounded-full",
};
export function Skeleton(props: SkeletonProps) {
const rounded = () => props.rounded ?? "md";
return (
<div
class={`skeleton shimmer ${roundedClasses[rounded()]} ${props.class ?? ""}`}
style={{
width: props.width,
height: props.height,
}}
/>
);
}
interface SkeletonCardProps {
lines?: number;
}
export function SkeletonCard(props: SkeletonCardProps) {
const lines = () => props.lines ?? 3;
return (
<div class="card space-y-3">
<Skeleton width="40%" height="14px" />
<For each={Array.from({ length: lines() })}>
{(_, i) => (
<Skeleton
width={i() === lines() - 1 ? "60%" : "100%"}
height="12px"
/>
)}
</For>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { type JSX, Show, createEffect, onCleanup, splitProps } from "solid-js";
import { Portal } from "solid-js/web";
interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
open: boolean;
onClose: () => void;
title?: string;
description?: string;
size?: "sm" | "md" | "lg" | "xl" | "full";
closeOnEscape?: boolean;
closeOnBackdrop?: boolean;
showClose?: boolean;
}
const sizeClasses = {
sm: "max-w-md",
md: "max-w-lg",
lg: "max-w-2xl",
xl: "max-w-4xl",
full: "max-w-full mx-4",
};
export function Modal(props: ModalProps) {
const [local, rest] = splitProps(props, [
"open",
"onClose",
"title",
"description",
"size",
"closeOnEscape",
"closeOnBackdrop",
"showClose",
"children",
"class",
]);
const size = () => local.size ?? "md";
const closeOnEscape = () => local.closeOnEscape ?? true;
const closeOnBackdrop = () => local.closeOnBackdrop ?? true;
const showClose = () => local.showClose ?? true;
createEffect(() => {
if (local.open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
});
createEffect(() => {
if (!local.open || !closeOnEscape()) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
local.onClose();
}
};
document.addEventListener("keydown", handleEscape);
onCleanup(() => document.removeEventListener("keydown", handleEscape));
});
onCleanup(() => {
document.body.style.overflow = "";
});
return (
<Show when={local.open}>
<Portal>
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby={local.title ? "modal-title" : undefined}
aria-describedby={local.description ? "modal-description" : undefined}
>
{/* Backdrop */}
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => closeOnBackdrop() && local.onClose()}
aria-hidden="true"
/>
{/* Modal content */}
<div
class={`relative w-full ${sizeClasses[size()]} bg-surface-1 border border-border-strong rounded-xl shadow-elevated animate-scale-in ${local.class ?? ""}`}
{...rest}
>
{/* Header */}
<Show when={local.title || showClose()}>
<div class="flex items-start justify-between p-6 border-b border-border">
<div class="flex-1">
<Show when={local.title}>
<h2 id="modal-title" class="text-xl font-semibold text-text-primary">
{local.title}
</h2>
</Show>
<Show when={local.description}>
<p id="modal-description" class="mt-1.5 text-sm text-text-secondary">
{local.description}
</p>
</Show>
</div>
<Show when={showClose()}>
<button
class="ml-4 p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
onClick={local.onClose}
aria-label="Close modal"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</Show>
{/* Body */}
<div class="p-6">{local.children}</div>
</div>
</div>
</Portal>
</Show>
);
}
interface ModalFooterProps extends JSX.HTMLAttributes<HTMLDivElement> {
align?: "left" | "right" | "between" | "center";
}
export function ModalFooter(props: ModalFooterProps) {
const [local, rest] = splitProps(props, ["align", "children", "class"]);
const alignClass = () => {
switch (local.align) {
case "left":
return "justify-start";
case "right":
return "justify-end";
case "between":
return "justify-between";
case "center":
return "justify-center";
default:
return "justify-end";
}
};
return (
<div
class={`flex items-center gap-3 px-6 pb-6 ${alignClass()} ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
);
}
@@ -0,0 +1,83 @@
import { Show, createSignal, onMount } from "solid-js";
import { enableDemoMode } from "../lib/demo-mode";
interface NetworkErrorProps {
error: string;
onRetry?: () => void;
onDismiss?: () => void;
}
export function NetworkError(props: NetworkErrorProps) {
const [visible, setVisible] = createSignal(true);
const handleDismiss = () => {
setVisible(false);
props.onDismiss?.();
};
const handleDemoMode = () => {
enableDemoMode();
};
return (
<Show when={visible()}>
<div class="network-error-toast">
<div class="network-error-content">
<svg class="network-error-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="network-error-text">
<div class="network-error-title">Connection Error</div>
<div class="network-error-message">{props.error}</div>
<div class="network-error-actions">
<Show when={props.onRetry}>
<button class="btn-sm btn-secondary" onClick={props.onRetry}>
Retry
</button>
</Show>
<button class="btn-sm btn-primary" onClick={handleDemoMode}>
Try Demo Mode
</button>
<button class="btn-sm btn-ghost" onClick={handleDismiss}>
Dismiss
</button>
</div>
</div>
</div>
</div>
</Show>
);
}
interface DemoBannerProps {
onExit?: () => void;
}
export function DemoBanner(props: DemoBannerProps) {
const [visible, setVisible] = createSignal(true);
const handleClose = () => {
setVisible(false);
};
return (
<Show when={visible()}>
<div class="demo-banner">
<svg class="demo-banner-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Demo Mode Active - All data is simulated</span>
<Show when={props.onExit}>
<button class="btn-sm btn-ghost text-white" onClick={props.onExit}>
Exit Demo
</button>
</Show>
<button class="demo-banner-close" onClick={handleClose} aria-label="Close">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Show>
);
}
@@ -0,0 +1,157 @@
import { Show, For, createSignal } from "solid-js";
import { Portal } from "solid-js/web";
export interface Notification {
id: string;
type: "success" | "error" | "warning" | "info";
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onClick: () => void;
};
}
const [notifications, setNotifications] = createSignal<Notification[]>([]);
export function addNotification(notification: Omit<Notification, "id">) {
const id = Math.random().toString(36).substring(7);
const newNotification: Notification = {
...notification,
id,
duration: notification.duration ?? 5000,
};
setNotifications(prev => [...prev, newNotification]);
if (newNotification.duration > 0) {
setTimeout(() => {
removeNotification(id);
}, newNotification.duration);
}
return id;
}
export function removeNotification(id: string) {
setNotifications(prev => prev.filter(n => n.id !== id));
}
export function clearNotifications() {
setNotifications([]);
}
export function NotificationCenter() {
const getIcon = (type: Notification["type"]) => {
switch (type) {
case "success":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case "error":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case "warning":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
case "info":
return (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
};
const getColors = (type: Notification["type"]) => {
switch (type) {
case "success":
return "bg-green-50 border-green-200 text-green-800";
case "error":
return "bg-red-50 border-red-200 text-red-800";
case "warning":
return "bg-yellow-50 border-yellow-200 text-yellow-800";
case "info":
return "bg-blue-50 border-blue-200 text-blue-800";
}
};
const getIconColors = (type: Notification["type"]) => {
switch (type) {
case "success":
return "text-green-600";
case "error":
return "text-red-600";
case "warning":
return "text-yellow-600";
case "info":
return "text-blue-600";
}
};
return (
<Portal>
<div class="fixed top-4 right-4 z-50 space-y-3 max-w-md">
<For each={notifications()}>
{(notification) => (
<div
class={`${getColors(notification.type)} border rounded-lg shadow-lg p-4 animate-slide-in-right`}
>
<div class="flex items-start gap-3">
<div class={`flex-shrink-0 ${getIconColors(notification.type)}`}>
{getIcon(notification.type)}
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-sm">{notification.title}</h4>
<Show when={notification.message}>
<p class="text-sm mt-1 opacity-90">{notification.message}</p>
</Show>
<Show when={notification.action}>
<button
onClick={notification.action!.onClick}
class="text-sm font-medium mt-2 underline hover:no-underline"
>
{notification.action!.label}
</button>
</Show>
</div>
<button
onClick={() => removeNotification(notification.id)}
class="flex-shrink-0 opacity-60 hover:opacity-100 transition-opacity"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
</For>
</div>
</Portal>
);
}
// Convenience functions
export const notify = {
success: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "success", title, message, ...options }),
error: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "error", title, message, duration: 7000, ...options }),
warning: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "warning", title, message, ...options }),
info: (title: string, message?: string, options?: Partial<Notification>) =>
addNotification({ type: "info", title, message, ...options }),
};
@@ -0,0 +1,130 @@
import { Show, createSignal } from "solid-js";
import { Button, Input, Modal } from "./index";
interface OnboardingModalProps {
isOpen: boolean;
projectName: string;
onClose: () => void;
}
export function OnboardingModal(props: OnboardingModalProps) {
const [step, setStep] = createSignal(1);
const [apiKey, setApiKey] = createSignal("");
const handleNext = () => {
if (step() < 3) {
setStep(step() + 1);
} else {
props.onClose();
}
};
const handleSkip = () => {
props.onClose();
};
return (
<Modal open={props.isOpen} onClose={props.onClose} title="Get Started with Your Project">
<div class="space-y-6">
<Show when={step() === 1}>
<div class="space-y-4">
<div class="text-center">
<div class="mx-auto w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Welcome to {props.projectName}!</h3>
<p class="text-gray-600 text-sm">
Let's get you set up in just a few steps. You'll be able to create API keys, set up storage, and start building.
</p>
</div>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div class="flex items-center gap-3">
<div class="w-6 h-6 rounded-full bg-blue-600 text-white flex items-center justify-center text-xs font-semibold">1</div>
<span class="text-sm">Create your first API key</span>
</div>
<div class="flex items-center gap-3">
<div class="w-6 h-6 rounded-full bg-gray-300 text-white flex items-center justify-center text-xs font-semibold">2</div>
<span class="text-sm">Set up storage buckets</span>
</div>
<div class="flex items-center gap-3">
<div class="w-6 h-6 rounded-full bg-gray-300 text-white flex items-center justify-center text-xs font-semibold">3</div>
<span class="text-sm">Connect your application</span>
</div>
</div>
</div>
</Show>
<Show when={step() === 2}>
<div class="space-y-4">
<div class="text-center mb-4">
<div class="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Create Your First API Key</h3>
<p class="text-gray-600 text-sm">
API keys allow your applications to authenticate with Primora services.
</p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p class="text-sm text-blue-800">
<strong>Tip:</strong> You can create API keys from the Settings tab. Each key can have different permissions and scopes.
</p>
</div>
</div>
</Show>
<Show when={step() === 3}>
<div class="space-y-4">
<div class="text-center mb-4">
<div class="mx-auto w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Connect Your Application</h3>
<p class="text-gray-600 text-sm mb-4">
Use the following code snippet to connect to your project:
</p>
</div>
<div class="bg-gray-900 rounded-lg p-4 text-sm font-mono text-gray-100 overflow-x-auto">
<pre>{`import { PrimoraClient } from '@primora/client';
const client = new PrimoraClient({
apiKey: 'your-api-key',
projectId: '${props.projectName.toLowerCase().replace(/\s+/g, '-')}'
});
// Upload a file
await client.storage.upload('bucket-name', file);`}</pre>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p class="text-sm text-yellow-800">
<strong>Documentation:</strong> Visit our docs to learn more about authentication, storage, and other features.
</p>
</div>
</div>
</Show>
<div class="flex justify-between pt-4 border-t">
<Button variant="ghost" onClick={handleSkip}>
Skip for now
</Button>
<div class="flex gap-2">
<Show when={step() > 1}>
<Button variant="outline" onClick={() => setStep(step() - 1)}>
Back
</Button>
</Show>
<Button onClick={handleNext}>
{step() === 3 ? "Get Started" : "Next"}
</Button>
</div>
</div>
</div>
</Modal>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { type JSX, Show, splitProps } from "solid-js";
interface ProgressProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: number;
max?: number;
size?: "sm" | "md" | "lg";
variant?: "default" | "success" | "warning" | "error";
showLabel?: boolean;
label?: string;
animated?: boolean;
}
const sizeClasses = {
sm: "h-1",
md: "h-2",
lg: "h-3",
};
const variantClasses = {
default: "bg-accent",
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
};
export function Progress(props: ProgressProps) {
const [local, rest] = splitProps(props, [
"value",
"max",
"size",
"variant",
"showLabel",
"label",
"animated",
"class",
]);
const max = () => local.max ?? 100;
const size = () => local.size ?? "md";
const variant = () => local.variant ?? "default";
const percentage = () => Math.min(100, Math.max(0, (local.value / max()) * 100));
return (
<div class={`w-full ${local.class ?? ""}`} {...rest}>
<Show when={local.showLabel || local.label}>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-text-secondary">{local.label ?? "Progress"}</span>
<span class="text-xs font-medium text-text-primary">{Math.round(percentage())}%</span>
</div>
</Show>
<div
class={`w-full bg-surface-2 rounded-full overflow-hidden ${sizeClasses[size()]}`}
role="progressbar"
aria-valuenow={local.value}
aria-valuemin={0}
aria-valuemax={max()}
>
<div
class={`h-full ${variantClasses[variant()]} transition-all duration-300 ease-out ${local.animated ? "animate-pulse" : ""}`}
style={{ width: `${percentage()}%` }}
/>
</div>
</div>
);
}
interface CircularProgressProps {
value: number;
max?: number;
size?: number;
strokeWidth?: number;
variant?: "default" | "success" | "warning" | "error";
showLabel?: boolean;
}
export function CircularProgress(props: CircularProgressProps) {
const max = () => props.max ?? 100;
const size = () => props.size ?? 64;
const strokeWidth = () => props.strokeWidth ?? 4;
const variant = () => props.variant ?? "default";
const percentage = () => Math.min(100, Math.max(0, (props.value / max()) * 100));
const radius = () => (size() - strokeWidth()) / 2;
const circumference = () => 2 * Math.PI * radius();
const offset = () => circumference() - (percentage() / 100) * circumference();
const colorMap = {
default: "var(--accent)",
success: "var(--success)",
warning: "var(--warning)",
error: "var(--error)",
};
return (
<div class="relative inline-flex items-center justify-center">
<svg
width={size()}
height={size()}
class="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke="var(--surface-2)"
stroke-width={strokeWidth()}
fill="none"
/>
{/* Progress circle */}
<circle
cx={size() / 2}
cy={size() / 2}
r={radius()}
stroke={colorMap[variant()]}
stroke-width={strokeWidth()}
fill="none"
stroke-dasharray={circumference()}
stroke-dashoffset={offset()}
stroke-linecap="round"
class="transition-all duration-300 ease-out"
/>
</svg>
<Show when={props.showLabel}>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-sm font-semibold text-text-primary">
{Math.round(percentage())}%
</span>
</div>
</Show>
</div>
);
}
interface SpinnerProps {
size?: "sm" | "md" | "lg";
variant?: "default" | "primary";
}
const spinnerSizeClasses = {
sm: "w-4 h-4 border-2",
md: "w-6 h-6 border-2",
lg: "w-8 h-8 border-[3px]",
};
export function Spinner(props: SpinnerProps) {
const size = () => props.size ?? "md";
const variant = () => props.variant ?? "default";
return (
<div
class={`${spinnerSizeClasses[size()]} rounded-full animate-spin ${
variant() === "primary"
? "border-accent border-t-transparent"
: "border-surface-2 border-t-accent"
}`}
role="status"
aria-label="Loading"
>
<span class="sr-only">Loading...</span>
</div>
);
}
@@ -0,0 +1,178 @@
import { Show, For } from "solid-js";
import { Card, StatCard, Badge, Button } from "./index";
import type { ProjectOverview } from "@primora/api-client";
interface ProjectDashboardProps {
project: { id: string; name: string; slug: string; description?: string };
overview?: ProjectOverview;
onNavigate: (view: string) => void;
}
export function ProjectDashboard(props: ProjectDashboardProps) {
const stats = () => [
{
label: "Storage",
value: props.overview?.storage_buckets_count ?? 0,
unit: "buckets",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
),
},
{
label: "API Keys",
value: props.overview?.api_keys_count ?? 0,
unit: "keys",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
),
},
{
label: "Members",
value: props.overview?.project_members_count ?? 0,
unit: "users",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
},
{
label: "Audit Logs",
value: props.overview?.audit_logs_count ?? 0,
unit: "events",
icon: (
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
},
];
return (
<div class="space-y-6">
{/* Project Header */}
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{props.project.name}</h1>
<Show when={props.project.description}>
<p class="text-gray-600 mt-1">{props.project.description}</p>
</Show>
<div class="flex items-center gap-2 mt-2">
<Badge variant="secondary">{props.project.slug}</Badge>
</div>
</div>
</div>
{/* Stats Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<For each={stats()}>
{(stat) => (
<Card class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">{stat.label}</p>
<p class="text-3xl font-bold text-gray-900">{stat.value}</p>
<p class="text-xs text-gray-500 mt-1">{stat.unit}</p>
</div>
<div class="text-gray-400">{stat.icon}</div>
</div>
</Card>
)}
</For>
</div>
{/* Usage Charts */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Bandwidth</h3>
<div class="h-48 flex items-center justify-center bg-gray-50 rounded-lg">
<div class="text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-sm">No data to show</p>
</div>
</div>
</Card>
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Requests</h3>
<div class="h-48 flex items-center justify-center bg-gray-50 rounded-lg">
<div class="text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
<p class="text-sm">No data to show</p>
</div>
</div>
</Card>
</div>
{/* Quick Actions */}
<Card class="p-6">
<h3 class="text-lg font-semibold mb-4">Quick Start</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => props.onNavigate("storage")}
class="p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors text-left"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 3h4m-4 4h4" />
</svg>
</div>
<h4 class="font-semibold">Create Bucket</h4>
</div>
<p class="text-sm text-gray-600">Set up storage for your files and assets</p>
</button>
<button
onClick={() => props.onNavigate("settings")}
class="p-4 border border-gray-200 rounded-lg hover:border-green-500 hover:bg-green-50 transition-colors text-left"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h4 class="font-semibold">Generate API Key</h4>
</div>
<p class="text-sm text-gray-600">Create keys to authenticate your apps</p>
</button>
<button
onClick={() => props.onNavigate("members")}
class="p-4 border border-gray-200 rounded-lg hover:border-purple-500 hover:bg-purple-50 transition-colors text-left"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h4 class="font-semibold">Invite Members</h4>
</div>
<p class="text-sm text-gray-600">Add team members to collaborate</p>
</button>
</div>
</Card>
{/* Documentation Link */}
<Card class="p-6 bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold mb-1">Need Help Getting Started?</h3>
<p class="text-sm text-gray-600">Check out our documentation and guides</p>
</div>
<Button variant="outline">View Docs</Button>
</div>
</Card>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { type JSX, For, Show, splitProps } from "solid-js";
interface Column<T> {
key: keyof T | string;
header: string;
width?: string;
align?: "left" | "center" | "right";
render?: (value: T[keyof T], row: T, index: number) => JSX.Element;
}
interface TableProps<T> extends JSX.HTMLAttributes<HTMLTableElement> {
columns: Column<T>[];
data: T[];
rowKey?: (row: T) => string | number;
onRowClick?: (row: T) => void;
loading?: boolean;
emptyMessage?: string;
stickyHeader?: boolean;
}
export function Table<T extends Record<string, unknown>>(props: TableProps<T>) {
const [local, rest] = splitProps(props, [
"columns",
"data",
"rowKey",
"onRowClick",
"loading",
"emptyMessage",
"stickyHeader",
"children",
"class",
]);
const getKeyValue = (row: T, key: keyof T | string): unknown => {
if (typeof key === "string" && key.includes(".")) {
const keys = key.split(".");
let value: unknown = row;
for (const k of keys) {
value = (value as Record<string, unknown>)?.[k];
}
return value;
}
return row[key as keyof T];
};
const alignClass = (align?: "left" | "center" | "right") => {
switch (align) {
case "center":
return "text-center";
case "right":
return "text-right";
default:
return "text-left";
}
};
return (
<div class="table-container">
<table class={`table ${local.class ?? ""}`} {...rest}>
<thead class={local.stickyHeader ? "sticky top-0 z-10" : ""}>
<tr>
<For each={local.columns}>
{(column) => (
<th
class={alignClass(column.align)}
style={{ width: column.width }}
>
{column.header}
</th>
)}
</For>
</tr>
</thead>
<tbody>
<Show when={!local.loading && local.data.length === 0}>
<tr>
<td
colspan={local.columns.length}
class="py-8 text-center text-text-muted"
>
{local.emptyMessage ?? "No data available"}
</td>
</tr>
</Show>
<For each={local.data}>
{(row, index) => (
<tr
class={local.onRowClick ? "cursor-pointer" : ""}
onClick={() => local.onRowClick?.(row)}
data-row-key={local.rowKey?.(row)}
>
<For each={local.columns}>
{(column) => {
const value = getKeyValue(row, column.key);
return (
<td class={alignClass(column.align)}>
<Show
when={column.render}
fallback={String(value ?? "")}
>
{column.render!(
value as T[keyof T],
row,
index()
)}
</Show>
</td>
);
}}
</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
);
}
interface DataTableProps<T> extends JSX.HTMLAttributes<HTMLDivElement> {
columns: Column<T>[];
data: T[];
rowKey?: (row: T) => string | number;
onRowClick?: (row: T) => void;
loading?: boolean;
emptyMessage?: string;
pageSize?: number;
showPagination?: boolean;
}
export function DataTable<T extends Record<string, unknown>>(
props: DataTableProps<T>
) {
const [local, rest] = splitProps(props, [
"columns",
"data",
"rowKey",
"onRowClick",
"loading",
"emptyMessage",
"pageSize",
"showPagination",
"children",
"class",
]);
return (
<div class={`space-y-4 ${local.class ?? ""}`} {...rest}>
<Table
columns={local.columns}
data={local.data}
rowKey={local.rowKey}
onRowClick={local.onRowClick}
loading={local.loading}
emptyMessage={local.emptyMessage}
stickyHeader
/>
<Show when={local.children}>{local.children}</Show>
</div>
);
}
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
showPageNumbers?: boolean;
}
export function Pagination(props: PaginationProps) {
const pages = () => {
const pages: (number | "ellipsis")[] = [];
const total = props.totalPages;
const current = props.currentPage;
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
pages.push(1);
if (current > 3) pages.push("ellipsis");
for (
let i = Math.max(2, current - 1);
i <= Math.min(total - 1, current + 1);
i++
) {
pages.push(i);
}
if (current < total - 2) pages.push("ellipsis");
pages.push(total);
}
return pages;
};
return (
<nav class="flex items-center justify-between" aria-label="Pagination">
<div class="text-xs text-text-muted">
Page {props.currentPage} of {props.totalPages}
</div>
<div class="flex items-center gap-1">
<button
class="btn-ghost btn-sm"
onClick={() => props.onPageChange(props.currentPage - 1)}
disabled={props.currentPage === 1}
aria-label="Previous page"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<Show when={props.showPageNumbers ?? true}>
<For each={pages()}>
{(page) =>
page === "ellipsis" ? (
<span class="px-2 text-text-muted">...</span>
) : (
<button
class={`btn-sm ${page === props.currentPage ? "btn-primary" : "btn-ghost"}`}
onClick={() => props.onPageChange(page)}
aria-current={page === props.currentPage ? "page" : undefined}
>
{page}
</button>
)
}
</For>
</Show>
<button
class="btn-ghost btn-sm"
onClick={() => props.onPageChange(props.currentPage + 1)}
disabled={props.currentPage === props.totalPages}
aria-label="Next page"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</nav>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { type JSX, For, Show, createSignal, splitProps } from "solid-js";
interface Tab {
id: string;
label: string;
icon?: JSX.Element;
badge?: string | number;
disabled?: boolean;
content?: JSX.Element;
}
interface TabsProps {
tabs: Tab[];
defaultTab?: string;
activeTab?: string;
onChange?: (tabId: string) => void;
variant?: "default" | "pills" | "underline";
size?: "sm" | "md" | "lg";
}
export function Tabs(props: TabsProps) {
const [local] = splitProps(props, ["tabs", "defaultTab", "activeTab", "onChange", "variant", "size"]);
const [internalActiveTab, setInternalActiveTab] = createSignal(local.defaultTab ?? local.tabs[0]?.id);
const activeTab = () => local.activeTab ?? internalActiveTab();
const variant = () => local.variant ?? "default";
const size = () => local.size ?? "md";
const handleTabChange = (tabId: string) => {
setInternalActiveTab(tabId);
local.onChange?.(tabId);
};
const sizeClasses = {
sm: "text-xs px-3 py-1.5",
md: "text-sm px-4 py-2",
lg: "text-base px-5 py-2.5",
};
const getTabClasses = (tab: Tab) => {
const isActive = activeTab() === tab.id;
const base = `${sizeClasses[size()]} font-medium transition-all duration-fast flex items-center gap-2`;
if (tab.disabled) {
return `${base} opacity-50 cursor-not-allowed`;
}
switch (variant()) {
case "pills":
return `${base} rounded-lg ${
isActive
? "bg-accent text-white shadow-sm"
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
}`;
case "underline":
return `${base} border-b-2 ${
isActive
? "border-accent text-accent"
: "border-transparent text-text-secondary hover:text-text-primary hover:border-border-hover"
}`;
default:
return `${base} rounded-lg ${
isActive
? "bg-surface-2 text-text-primary"
: "text-text-secondary hover:text-text-primary hover:bg-surface-1"
}`;
}
};
return (
<div class="w-full">
<div
class={`flex gap-1 ${variant() === "underline" ? "border-b border-border" : ""}`}
role="tablist"
>
<For each={local.tabs}>
{(tab) => (
<button
class={getTabClasses(tab)}
onClick={() => !tab.disabled && handleTabChange(tab.id)}
disabled={tab.disabled}
role="tab"
aria-selected={activeTab() === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
>
<Show when={tab.icon}>
<span class="flex-shrink-0">{tab.icon}</span>
</Show>
<span>{tab.label}</span>
<Show when={tab.badge}>
<span class="badge-neutral text-2xs">{tab.badge}</span>
</Show>
</button>
)}
</For>
</div>
<div class="mt-4">
<For each={local.tabs}>
{(tab) => (
<Show when={activeTab() === tab.id}>
<div
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
class="animate-fade-in"
>
{tab.content}
</div>
</Show>
)}
</For>
</div>
</div>
);
}
interface TabPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
activeValue: string;
}
export function TabPanel(props: TabPanelProps) {
const [local, rest] = splitProps(props, ["value", "activeValue", "children", "class"]);
return (
<Show when={local.value === local.activeValue}>
<div
role="tabpanel"
class={`animate-fade-in ${local.class ?? ""}`}
{...rest}
>
{local.children}
</div>
</Show>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { type JSX, For, Show, createSignal } from "solid-js";
import { Portal } from "solid-js/web";
type ToastVariant = "info" | "success" | "warning" | "error";
interface Toast {
id: string;
variant: ToastVariant;
title?: string;
message: string;
duration?: number;
icon?: JSX.Element;
}
const [toasts, setToasts] = createSignal<Toast[]>([]);
const variantConfig = {
info: {
bg: "bg-info-muted",
border: "border-info/25",
text: "text-info",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
success: {
bg: "bg-success-muted",
border: "border-success/25",
text: "text-success",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
warning: {
bg: "bg-warning-muted",
border: "border-warning/25",
text: "text-warning",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
},
error: {
bg: "bg-error-muted",
border: "border-error/25",
text: "text-error",
icon: (
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
};
export const toast = {
show: (options: Omit<Toast, "id">) => {
const id = Math.random().toString(36).substring(7);
const duration = options.duration ?? 5000;
setToasts((prev) => [...prev, { ...options, id }]);
if (duration > 0) {
setTimeout(() => {
toast.dismiss(id);
}, duration);
}
return id;
},
success: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "success", message, title, duration });
},
error: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "error", message, title, duration });
},
warning: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "warning", message, title, duration });
},
info: (message: string, title?: string, duration?: number) => {
return toast.show({ variant: "info", message, title, duration });
},
dismiss: (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
},
dismissAll: () => {
setToasts([]);
},
};
export function ToastContainer() {
return (
<Portal>
<div
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full pointer-events-none"
aria-live="polite"
aria-atomic="true"
>
<For each={toasts()}>
{(toast) => {
const config = variantConfig[toast.variant];
return (
<div
class={`${config.bg} ${config.border} border rounded-lg shadow-lg p-4 flex gap-3 animate-slide-up pointer-events-auto`}
role="alert"
>
<div class={`flex-shrink-0 ${config.text}`}>
{toast.icon ?? config.icon}
</div>
<div class="flex-1 min-w-0">
<Show when={toast.title}>
<p class={`font-semibold text-sm ${config.text}`}>{toast.title}</p>
</Show>
<p class={`text-sm ${toast.title ? "mt-1" : ""} ${config.text}`}>
{toast.message}
</p>
</div>
<button
class={`flex-shrink-0 ${config.text} opacity-70 hover:opacity-100 transition-opacity`}
onClick={() => toast.dismiss(toast.id)}
aria-label="Dismiss notification"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}}
</For>
</div>
</Portal>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { type JSX, Show, createSignal, splitProps, onMount, onCleanup } from "solid-js";
import { Portal } from "solid-js/web";
interface TooltipProps {
content: string | JSX.Element;
placement?: "top" | "bottom" | "left" | "right";
delay?: number;
children: JSX.Element;
disabled?: boolean;
}
export function Tooltip(props: TooltipProps) {
const [local] = splitProps(props, ["content", "placement", "delay", "children", "disabled"]);
const [show, setShow] = createSignal(false);
const [position, setPosition] = createSignal({ x: 0, y: 0 });
let triggerRef: HTMLElement | undefined;
let tooltipRef: HTMLDivElement | undefined;
let timeoutId: number | undefined;
const placement = () => local.placement ?? "top";
const delay = () => local.delay ?? 200;
const calculatePosition = () => {
if (!triggerRef || !tooltipRef) return;
const triggerRect = triggerRef.getBoundingClientRect();
const tooltipRect = tooltipRef.getBoundingClientRect();
const gap = 8;
let x = 0;
let y = 0;
switch (placement()) {
case "top":
x = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
y = triggerRect.top - tooltipRect.height - gap;
break;
case "bottom":
x = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
y = triggerRect.bottom + gap;
break;
case "left":
x = triggerRect.left - tooltipRect.width - gap;
y = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
break;
case "right":
x = triggerRect.right + gap;
y = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
break;
}
// Keep tooltip within viewport
x = Math.max(8, Math.min(x, window.innerWidth - tooltipRect.width - 8));
y = Math.max(8, Math.min(y, window.innerHeight - tooltipRect.height - 8));
setPosition({ x, y });
};
const handleMouseEnter = () => {
if (local.disabled) return;
timeoutId = window.setTimeout(() => {
setShow(true);
requestAnimationFrame(calculatePosition);
}, delay());
};
const handleMouseLeave = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
setShow(false);
};
onCleanup(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
return (
<>
<span
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
onBlur={handleMouseLeave}
>
{local.children}
</span>
<Show when={show() && !local.disabled}>
<Portal>
<div
ref={tooltipRef}
class="fixed z-50 px-3 py-2 text-xs font-medium text-text-primary bg-surface-3 border border-border-strong rounded-lg shadow-lg animate-fade-in pointer-events-none"
style={{
left: `${position().x}px`,
top: `${position().y}px`,
}}
role="tooltip"
>
{local.content}
</div>
</Portal>
</Show>
</>
);
}
@@ -0,0 +1,79 @@
import { type JSX, For, createSignal, createEffect, onMount, onCleanup } from "solid-js";
interface VirtualListProps<T> {
items: T[];
itemHeight: number;
height: number;
overscan?: number;
renderItem: (item: T, index: number) => JSX.Element;
class?: string;
}
/**
* High-performance virtual list component for rendering large datasets
* Only renders visible items + overscan buffer
*/
export function VirtualList<T>(props: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = createSignal(0);
let containerRef: HTMLDivElement | undefined;
const overscan = () => props.overscan ?? 3;
const totalHeight = () => props.items.length * props.itemHeight;
// Calculate visible range
const visibleRange = () => {
const start = Math.floor(scrollTop() / props.itemHeight);
const end = Math.ceil((scrollTop() + props.height) / props.itemHeight);
return {
start: Math.max(0, start - overscan()),
end: Math.min(props.items.length, end + overscan()),
};
};
const visibleItems = () => {
const range = visibleRange();
return props.items.slice(range.start, range.end).map((item, i) => ({
item,
index: range.start + i,
}));
};
const handleScroll = (e: Event) => {
const target = e.target as HTMLDivElement;
setScrollTop(target.scrollTop);
};
onMount(() => {
containerRef?.addEventListener("scroll", handleScroll, { passive: true });
});
onCleanup(() => {
containerRef?.removeEventListener("scroll", handleScroll);
});
return (
<div
ref={containerRef}
class={`overflow-auto ${props.class ?? ""}`}
style={{ height: `${props.height}px` }}
>
<div style={{ height: `${totalHeight()}px`, position: "relative" }}>
<For each={visibleItems()}>
{({ item, index }) => (
<div
style={{
position: "absolute",
top: `${index * props.itemHeight}px`,
height: `${props.itemHeight}px`,
width: "100%",
}}
>
{props.renderItem(item, index)}
</div>
)}
</For>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
// Components barrel export
export { Badge, StatusBadge } from "./Badge";
export { Button } from "./Button";
export { Card, CardHeader, CardContent, CardFooter, StatCard } from "./Card";
export { CommandPalette } from "./CommandPalette";
export { CommandPaletteEnhanced } from "./CommandPaletteEnhanced";
export { DataTable } from "./DataTable";
export { Dropdown } from "./Dropdown";
export { Input, Textarea, Select, FileInput } from "./Input";
export { Layout, Sidebar, Header, PageHeader, EmptyState } from "./Layout";
export { Logo, LogoIcon } from "./Logo";
export { Message, Loading, Skeleton, SkeletonCard } from "./Message";
export { Modal, ModalFooter } from "./Modal";
export { NotificationCenter, addNotification, removeNotification, clearNotifications, notify } from "./NotificationCenter";
export { OnboardingModal } from "./OnboardingModal";
export { Progress, CircularProgress, Spinner } from "./Progress";
export { ProjectDashboard } from "./ProjectDashboard";
export { Table, DataTable as DataTableOld, Pagination } from "./Table";
export { Tabs, TabPanel } from "./Tabs";
export { ToastContainer, toast } from "./Toast";
export { Tooltip } from "./Tooltip";
export { VirtualList } from "./VirtualList";
export { NetworkError, DemoBanner } from "./NetworkError";
@@ -0,0 +1,68 @@
import { createSignal, onMount, onCleanup, Accessor } from "solid-js";
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}
/**
* Hook to observe element visibility using Intersection Observer API
* Useful for lazy loading, infinite scroll, and animations on scroll
*/
export function useIntersectionObserver(
elementRef: Accessor<HTMLElement | undefined>,
options: UseIntersectionObserverOptions = {}
): Accessor<IntersectionObserverEntry | undefined> {
const [entry, setEntry] = createSignal<IntersectionObserverEntry>();
const { freezeOnceVisible = false, ...observerOptions } = options;
onMount(() => {
const element = elementRef();
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
setEntry(entry);
if (freezeOnceVisible && entry.isIntersecting) {
observer.disconnect();
}
},
observerOptions
);
observer.observe(element);
onCleanup(() => {
observer.disconnect();
});
});
return entry;
}
/**
* Hook for lazy loading images
*/
export function useLazyImage(
imageRef: Accessor<HTMLImageElement | undefined>,
src: string
): Accessor<boolean> {
const [loaded, setLoaded] = createSignal(false);
const entry = useIntersectionObserver(imageRef, {
threshold: 0.1,
freezeOnceVisible: true,
});
createSignal(() => {
const img = imageRef();
const isVisible = entry()?.isIntersecting;
if (img && isVisible && !loaded()) {
img.src = src;
img.onload = () => setLoaded(true);
}
});
return loaded;
}
@@ -0,0 +1,86 @@
import { onCleanup, onMount } from "solid-js";
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
description?: string;
action: (event: KeyboardEvent) => void;
preventDefault?: boolean;
}
/**
* Hook for registering keyboard shortcuts
*/
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
const handleKeyDown = (event: KeyboardEvent) => {
for (const shortcut of shortcuts) {
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase();
const ctrlMatches = shortcut.ctrl === undefined || event.ctrlKey === shortcut.ctrl;
const shiftMatches = shortcut.shift === undefined || event.shiftKey === shortcut.shift;
const altMatches = shortcut.alt === undefined || event.altKey === shortcut.alt;
const metaMatches = shortcut.meta === undefined || event.metaKey === shortcut.meta;
if (keyMatches && ctrlMatches && shiftMatches && altMatches && metaMatches) {
if (shortcut.preventDefault !== false) {
event.preventDefault();
}
shortcut.action(event);
break;
}
}
};
onMount(() => {
document.addEventListener("keydown", handleKeyDown);
});
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown);
});
}
/**
* Format shortcut for display
*/
export function formatShortcut(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
if (shortcut.ctrl) parts.push(isMac ? "⌃" : "Ctrl");
if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
if (shortcut.meta) parts.push(isMac ? "⌘" : "Win");
parts.push(shortcut.key.toUpperCase());
return parts.join(isMac ? "" : "+");
}
/**
* Common keyboard shortcuts
*/
export const commonShortcuts = {
save: { key: "s", ctrl: true, meta: true, description: "Save" },
copy: { key: "c", ctrl: true, meta: true, description: "Copy" },
paste: { key: "v", ctrl: true, meta: true, description: "Paste" },
cut: { key: "x", ctrl: true, meta: true, description: "Cut" },
undo: { key: "z", ctrl: true, meta: true, description: "Undo" },
redo: { key: "z", ctrl: true, meta: true, shift: true, description: "Redo" },
selectAll: { key: "a", ctrl: true, meta: true, description: "Select All" },
find: { key: "f", ctrl: true, meta: true, description: "Find" },
newTab: { key: "t", ctrl: true, meta: true, description: "New Tab" },
closeTab: { key: "w", ctrl: true, meta: true, description: "Close Tab" },
refresh: { key: "r", ctrl: true, meta: true, description: "Refresh" },
commandPalette: { key: "k", ctrl: true, meta: true, description: "Command Palette" },
settings: { key: ",", ctrl: true, meta: true, description: "Settings" },
escape: { key: "Escape", description: "Cancel/Close" },
enter: { key: "Enter", description: "Confirm/Submit" },
arrowUp: { key: "ArrowUp", description: "Move Up" },
arrowDown: { key: "ArrowDown", description: "Move Down" },
arrowLeft: { key: "ArrowLeft", description: "Move Left" },
arrowRight: { key: "ArrowRight", description: "Move Right" },
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAPI } from '@primora/api-client';
describe('API Configuration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should configure base URL from environment', () => {
expect(OpenAPI.BASE).toBeDefined();
expect(OpenAPI.BASE).toContain('/api/v1');
});
it('should include credentials in requests', () => {
expect(OpenAPI.CREDENTIALS).toBe('include');
expect(OpenAPI.WITH_CREDENTIALS).toBe(true);
});
it('should have token resolver configured', () => {
expect(OpenAPI.TOKEN).toBeDefined();
expect(typeof OpenAPI.TOKEN).toBe('function');
});
});

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