mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-06-04 04:23:00 +00:00
initiall commit
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) || '%'
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 *;
|
||||
@@ -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;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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 *;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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! 🚀**
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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! 🚀**
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user