Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
DeploymentDef,
DeploymentsCollection,
ProjectsCollection,
type Task,
TaskCountersCollection,
TasksCollection,
TeamDef,
TeamDetailDef,
User,
Expand Down Expand Up @@ -143,6 +146,36 @@ const userInTeam = async (teamId: string, userId?: string) => {
return !!matches
}

const withProjectAccess = async (
ctx: RequestContext & { session: User },
slug: string,
) => {
const project = ProjectsCollection.get(slug)
if (!project) {
throw new respond.NotFoundError({ message: 'Project not found' })
}
if (!project.isPublic && !ctx.session.isAdmin) {
if (!(await userInTeam(project.teamId, ctx.session.id))) {
throw new respond.ForbiddenError({
message: 'Access to project tasks denied',
})
}
}
return project
}

// Allocates the next incremental task number for a project. The counter Map
// mutation happens synchronously (before the first await) so concurrent
// calls cannot observe a stale value.
const nextTaskNumber = (projectSlug: string) => {
const counter = TaskCountersCollection.get(projectSlug)
const lastNumber = (counter?.lastNumber ?? 0) + 1
const save = counter
? TaskCountersCollection.update(projectSlug, { lastNumber })
: TaskCountersCollection.insert({ projectSlug, lastNumber })
return save.then(() => lastNumber)
}

const withDeploymentTableAccess = async (
ctx: RequestContext & { session: User },
deployment: string,
Expand Down Expand Up @@ -192,6 +225,21 @@ const projectOutput = OBJ({
updatedAt: optional(NUM('The last update date of the project')),
})

const taskOutput = OBJ({
id: STR('The unique identifier for the task'),
projectSlug: STR('The slug of the project this task belongs to'),
number: NUM('The incremental number of the task within its project'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[A-Z]+-[0-9]+

title: STR('The title of the task'),
status: LIST(['todo', 'in_progress', 'done'], 'The status of the task'),
priority: LIST(['low', 'medium', 'high'], 'The priority of the task'),
description: optional(STR('Markdown description of the task')),
authorId: STR('The ID of the user who created the task'),
assigneeIds: ARR(STR('The ID of an assigned user'), 'Assigned users'),
position: NUM('Ordering position of the task within its status column'),
createdAt: optional(NUM('The creation date of the task')),
updatedAt: optional(NUM('The last update date of the task')),
})

const apiDocOutputDef = ARR(
OBJ({
method: LIST(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], 'HTTP method'),
Expand Down Expand Up @@ -855,6 +903,98 @@ const defs = {
output: apiDocOutputDef,
description: 'Get API documentation from the deployment',
}),
'GET/api/project/tasks': route({
authorize: withUserSession,
fn: async (ctx, { project }) => {
await withProjectAccess(ctx, project)
return TasksCollection.filter((t) => t.projectSlug === project)
.sort((a, b) => a.position - b.position)
},
input: OBJ({ project: STR('The slug of the project') }),
output: ARR(taskOutput, 'List of tasks for the project'),
description: 'Get all tasks for a project',
}),
'POST/api/task': route({
authorize: withUserSession,
fn: async (ctx, input) => {
await withProjectAccess(ctx, input.projectSlug)
const number = await nextTaskNumber(input.projectSlug)
const task = await TasksCollection.insert({
id: `${input.projectSlug}-${number}`,
projectSlug: input.projectSlug,
number,
title: input.title,
status: 'todo',
priority: input.priority || 'medium',
description: input.description,
authorId: ctx.session.id,
assigneeIds: input.assigneeIds || [],
position: Date.now(),
})
log.info('task-created', { id: task.id, projectSlug: input.projectSlug })
return task
},
input: OBJ({
projectSlug: STR('The slug of the project'),
title: STR('The title of the task'),
description: optional(STR('Markdown description of the task')),
priority: optional(
LIST(['low', 'medium', 'high'], 'Priority of the task'),
),
assigneeIds: optional(
ARR(STR('The ID of an assigned user'), 'Assigned users'),
),
}, 'Create a new task'),
output: taskOutput,
description: 'Create a new task in a project',
}),
'PUT/api/task': route({
authorize: withUserSession,
fn: async (ctx, { id, ...changes }) => {
const task = TasksCollection.get(id)
if (!task) throw new respond.NotFoundError({ message: 'Task not found' })
await withProjectAccess(ctx, task.projectSlug)
const patch = Object.fromEntries(
Object.entries(changes).filter(([, v]) => v != null),
) as Partial<Omit<Task, 'id'>>
const updated = await TasksCollection.update(id, patch)
log.info('task-updated', { id })
return updated
},
input: OBJ({
id: STR('The ID of the task'),
title: optional(STR('The title of the task')),
status: optional(
LIST(['todo', 'in_progress', 'done'], 'Status of the task'),
),
priority: optional(
LIST(['low', 'medium', 'high'], 'Priority of the task'),
),
description: optional(STR('Markdown description of the task')),
assigneeIds: optional(
ARR(STR('The ID of an assigned user'), 'Assigned users'),
),
position: optional(
NUM('Ordering position of the task within its status column'),
),
}),
output: taskOutput,
description: 'Update a task',
}),
'DELETE/api/task': route({
authorize: withUserSession,
fn: async (ctx, { id }) => {
const task = TasksCollection.get(id)
if (!task) throw new respond.NotFoundError({ message: 'Task not found' })
await withProjectAccess(ctx, task.projectSlug)
await TasksCollection.delete(id)
log.info('task-deleted', { id })
return true
},
input: OBJ({ id: STR('The ID of the task') }),
output: BOOL('Indicates if the task was deleted'),
description: 'Delete a task',
}),
'POST/api/deployment/fix-query': route({
authorize: withUserSession,
fn: async (ctx, { id, deployment, metric, forceRefresh }) => {
Expand Down
29 changes: 29 additions & 0 deletions api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,35 @@ export const DatabaseSchemasCollection = await createCollection<
'deploymentUrl'
>({ name: 'db_schemas', primaryKey: 'deploymentUrl' })

export const TaskDef = OBJ({
id: STR('Composite task ID, e.g. my-project-42'),
projectSlug: STR('Slug of the project this task belongs to'),
number: NUM('Incremental number of the task within its project'),
title: STR('Title of the task'),
status: LIST(['todo', 'in_progress', 'done'], 'Status of the task'),
priority: LIST(['low', 'medium', 'high'], 'Priority of the task'),
description: optional(STR('Markdown description of the task')),
authorId: STR('ID of the user who created the task'),
assigneeIds: ARR(STR('ID of an assigned user'), 'Assigned users'),
position: NUM('Ordering position of the task within its status column'),
}, 'A task belonging to a project')
export type Task = Asserted<typeof TaskDef>

export const TaskCounterDef = OBJ({
projectSlug: STR('Slug of the project'),
lastNumber: NUM('Last task number issued for this project'),
}, 'Per-project incremental task number counter')
export type TaskCounter = Asserted<typeof TaskCounterDef>

export const TasksCollection = await createCollection<Task, 'id'>(
{ name: 'tasks', primaryKey: 'id' },
)

export const TaskCountersCollection = await createCollection<
TaskCounter,
'projectSlug'
>({ name: 'task_counters', primaryKey: 'projectSlug' })

export const AIAnalysisCacheDef = OBJ({
cacheKey: STR('SHA-1 hash of deployment + query + explain + status'),
analysis: STR('HTML-rendered AI analysis (high-quality, from forceRefresh)'),
Expand Down
23 changes: 23 additions & 0 deletions web/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Signal } from '@preact/signals'
import { AlertTriangle, CheckCircle2 } from 'lucide-preact'

const toastSignal = new Signal<
{ message: string; type: 'info' | 'error' } | null
>(null)

export function toast(message: string, type: 'info' | 'error' = 'info') {
toastSignal.value = { message, type }
setTimeout(() => (toastSignal.value = null), 3000)
}

export const Toast = () => {
if (!toastSignal.value) return null
return (
<div class='fixed bottom-4 right-4 bg-base-200 shadow-lg rounded-lg p-4 text-sm flex items-center gap-3 z-[100] border border-base-300'>
{toastSignal.value.type === 'error'
? <AlertTriangle class='w-5 h-5 text-error' />
: <CheckCircle2 class='w-5 h-5 text-success' />}
<span class='text-base-content'>{toastSignal.value.message}</span>
</div>
)
}
9 changes: 8 additions & 1 deletion web/lib/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Book, HardDrive, ListTodo } from 'lucide-preact'
import { api } from './api.ts'
import { DeploymentPage } from '../pages/DeploymentPage.tsx'
import { ApiDocPage } from '../pages/project/ApiDocPage.tsx'
import { TasksPage } from '../pages/project/TaskPage.tsx'
import { SidebarItem } from '../components/SideBar.tsx'
import { url } from '@01edu/signal-router'
import { Signal } from '@preact/signals'
Expand All @@ -26,7 +27,7 @@ export const sidebarItems: Record<string, SidebarItem> = {
label: 'API Docs',
component: ApiDocPage,
},
'tasks': { icon: ListTodo, label: 'Tasks', component: DeploymentPage },
'tasks': { icon: ListTodo, label: 'Tasks', component: TasksPage },
} as const

// API signal for deployment queries
Expand All @@ -38,6 +39,12 @@ export const deployments = api['GET/api/project/deployments'].signal()
// API signal for current project
export const project = api['GET/api/project'].signal()

// API signal for the project's tasks
export const tasks = api['GET/api/project/tasks'].signal()

// API signal for the project's team (used to resolve assignee names)
export const projectTeam = api['GET/api/team'].signal()

export const runQuery = (query?: string) => {
if (querier.pending) return
const { dep, tab } = url.params
Expand Down
22 changes: 1 addition & 21 deletions web/pages/DeploymentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { api, type ApiOutput } from '../lib/api.ts'
import { highlightSQL } from '../lib/highlight-sql.ts'
import { QueryHistory } from '../components/QueryHistory.tsx'
import { DeploymentHeader } from '../components/DeploymentHeader.tsx'
import { Toast, toast } from '../components/Toast.tsx'
import type { ComponentChildren } from 'preact'
import { querier, queriesHistory, runQuery } from '../lib/shared.tsx'
import { apiDocs, TypeDefinition } from './project/ApiDocPage.tsx'
Expand All @@ -59,27 +60,6 @@ export const logDetailsData = api['POST/api/deployment/logs'].signal()
export const metricsData = api['GET/api/deployment/metrics-sql'].signal()
const routerMetricsData = api['GET/api/deployment/metrics-router'].signal()

const toastSignal = new Signal<
{ message: string; type: 'info' | 'error' } | null
>(null)

function toast(message: string, type: 'info' | 'error' = 'info') {
toastSignal.value = { message, type }
setTimeout(() => (toastSignal.value = null), 3000)
}

const Toast = () => {
if (!toastSignal.value) return null
return (
<div class='fixed bottom-4 right-4 bg-base-200 shadow-lg rounded-lg p-4 text-sm flex items-center gap-3 z-[100] border border-base-300'>
{toastSignal.value.type === 'error' && (
<AlertTriangle class='w-5 h-5 text-error' />
)}
<span class='text-base-content'>{toastSignal.value.message}</span>
</div>
)
}

type ColumnDef = NonNullable<
ApiOutput['GET/api/deployment/schema']['tables']
>[number]['columns'][number]
Expand Down
15 changes: 14 additions & 1 deletion web/pages/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { effect } from '@preact/signals'
import { navigate, url } from '@01edu/signal-router'
import { user } from '../lib/session.ts'
import { SettingsPage } from './SettingsPage.tsx'
import { deployments, project, sidebarItems } from '../lib/shared.tsx'
import {
deployments,
project,
projectTeam,
sidebarItems,
tasks,
} from '../lib/shared.tsx'
import { Sidebar } from '../components/SideBar.tsx'

effect(() => {
Expand All @@ -11,6 +17,13 @@ effect(() => {
if (!slug) return
project.fetch({ slug })
deployments.fetch({ project: slug })
tasks.fetch({ project: slug })
})

effect(() => {
const teamId = project.data?.teamId
if (!teamId) return
projectTeam.fetch({ id: teamId })
})

export function ProjectPage() {
Expand Down
25 changes: 2 additions & 23 deletions web/pages/ProjectsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { effect, signal, useSignal } from '@preact/signals'
import { effect, useSignal } from '@preact/signals'
import { A, navigate } from '@01edu/signal-router'
import {
AlertTriangle,
ArrowRight,
Calendar,
Folder,
Expand All @@ -13,6 +12,7 @@ import {
Users,
} from 'lucide-preact'
import { Dialog, DialogModal } from '../components/Dialog.tsx'
import { Toast, toast } from '../components/Toast.tsx'
import { url } from '@01edu/signal-router'
import { JSX } from 'preact'
import { user } from '../lib/session.ts'
Expand All @@ -27,10 +27,6 @@ teams.fetch()
const projects = api['GET/api/projects'].signal()
projects.fetch()

const toastSignal = signal<{ message: string; type: 'info' | 'error' } | null>(
null,
)

const slugify = (str: string) =>
str.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '')

Expand Down Expand Up @@ -71,11 +67,6 @@ async function saveProject(
}
}

function toast(message: string, type: 'info' | 'error' = 'info') {
toastSignal.value = { message, type }
setTimeout(() => (toastSignal.value = null), 3000)
}

async function deleteProject(slug: string) {
try {
await api['DELETE/api/project'].fetch({ slug: slug })
Expand Down Expand Up @@ -162,18 +153,6 @@ const ProjectCard = (
)
}

const Toast = () => {
if (!toastSignal.value) return null
return (
<div class='fixed bottom-4 right-4 bg-surface shadow-lg rounded-lg p-4 text-sm flex items-center gap-3 z-50'>
{toastSignal.value.type === 'error' && (
<AlertTriangle class='w-5 h-5 text-danger' />
)}
<span class='text-text'>{toastSignal.value.message}</span>
</div>
)
}

const TeamMembersRow = ({ user }: { user: Team['members'][number] }) => (
<tr class='border-b border-divider'>
<td class='py-3'>
Expand Down
Loading
Loading