pgkiln 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/SECURITY.md +5 -0
  2. package/db/migrations/121_ai_agents.sql +94 -0
  3. package/db/migrations/122_team_development.sql +186 -0
  4. package/db/migrations/123_workflow_outcomes.sql +407 -0
  5. package/db/migrations/124_component_details.sql +51 -0
  6. package/docs/apex-feature-parity.md +12 -10
  7. package/docs/guide/01-installation.md +7 -3
  8. package/docs/guide/03-builder.md +121 -7
  9. package/docs/guide/04-pages-and-regions.md +71 -21
  10. package/docs/guide/05-items.md +7 -1
  11. package/docs/guide/06-processing.md +123 -8
  12. package/docs/guide/07-dynamic-actions.md +34 -0
  13. package/docs/guide/09-reference.md +2 -1
  14. package/docs/guide/11-from-apex.md +2 -1
  15. package/docs/guide/12-development.md +44 -8
  16. package/docs/guide/20-ai-agents.md +4 -2
  17. package/examples/hr/README.md +2 -0
  18. package/examples/hr/hr_64_ai_agent.sql +97 -0
  19. package/examples/hr/hr_65_ask_reports.sql +21 -0
  20. package/examples/hr/hr_66_team_development.sql +58 -0
  21. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  22. package/examples/hr/hr_68_component_details.sql +96 -0
  23. package/package.json +1 -1
  24. package/public/app.css +40 -0
  25. package/public/app.js +134 -20
  26. package/public/builder.css +83 -1
  27. package/public/builder.js +47 -0
  28. package/src/ai/chat.ts +18 -18
  29. package/src/ai/service.ts +35 -11
  30. package/src/ai/types.ts +7 -2
  31. package/src/app.ts +6 -0
  32. package/src/builder/advisor.ts +6 -3
  33. package/src/builder/ai-agents.ts +372 -0
  34. package/src/builder/ai-builder.ts +3 -3
  35. package/src/builder/ai.ts +13 -9
  36. package/src/builder/assistant.ts +22 -7
  37. package/src/builder/components.ts +38 -9
  38. package/src/builder/designer.ts +8 -3
  39. package/src/builder/feedback.ts +6 -1
  40. package/src/builder/locks.ts +7 -1
  41. package/src/builder/region-help.ts +2 -2
  42. package/src/builder/routes.ts +4 -1
  43. package/src/builder/shared.ts +1 -1
  44. package/src/builder/team.ts +991 -0
  45. package/src/builder/ui.ts +2 -1
  46. package/src/builder/workflow-migrate.ts +210 -0
  47. package/src/builder/workflows.ts +29 -6
  48. package/src/builder/workspace-home.ts +2 -0
  49. package/src/builder/workspace-transfer.ts +1 -1
  50. package/src/cli/replace.ts +7 -1
  51. package/src/i18n/ar.ts +17 -0
  52. package/src/i18n/cs.ts +17 -0
  53. package/src/i18n/da.ts +17 -0
  54. package/src/i18n/de.ts +17 -0
  55. package/src/i18n/el.ts +17 -0
  56. package/src/i18n/es.ts +17 -0
  57. package/src/i18n/fi.ts +17 -0
  58. package/src/i18n/fr.ts +17 -0
  59. package/src/i18n/he.ts +17 -0
  60. package/src/i18n/it.ts +17 -0
  61. package/src/i18n/ja.ts +17 -0
  62. package/src/i18n/ko.ts +17 -0
  63. package/src/i18n/nb.ts +17 -0
  64. package/src/i18n/pl.ts +17 -0
  65. package/src/i18n/pt.ts +17 -0
  66. package/src/i18n/ru.ts +17 -0
  67. package/src/i18n/sv.ts +17 -0
  68. package/src/i18n/tr.ts +17 -0
  69. package/src/i18n/uk.ts +17 -0
  70. package/src/i18n/zh.ts +17 -0
  71. package/src/i18n.ts +34 -0
  72. package/src/instance.ts +2 -0
  73. package/src/metadata.ts +9 -3
  74. package/src/runtime/ai-agents.ts +365 -0
  75. package/src/runtime/ai-filter.ts +385 -107
  76. package/src/runtime/assistant.ts +64 -11
  77. package/src/runtime/context.ts +2 -0
  78. package/src/runtime/ir-phrases.ts +812 -0
  79. package/src/runtime/ir-plan.ts +449 -0
  80. package/src/runtime/items.ts +1 -1
  81. package/src/runtime/regions.ts +6 -2
  82. package/src/runtime/render.ts +1 -1
  83. package/src/runtime/report.ts +1 -1
  84. package/src/runtime/tasks.ts +63 -12
  85. package/src/task-events.ts +232 -0
  86. package/src/team-samples.ts +74 -0
  87. package/src/team.ts +342 -0
  88. package/src/workflow.ts +135 -18
  89. package/src/workspace-export.ts +16 -1
@@ -0,0 +1,991 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { owner, type Client } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { clientIp, logActivity, type Session } from '../session.ts';
6
+ import {
7
+ appIssueCounts, isLabelColor, issueHtml, LABEL_COLORS, logIssue, pageIssues, recordMentions, teamCounts, teamSetup, workspaceDevelopers,
8
+ } from '../team.ts';
9
+ import { TEAM_LANGUAGES, TEAM_SAMPLES } from '../team-samples.ts';
10
+ import { ago } from './home.ts';
11
+ import { back, BASE, csrf, designerUrl, developer, flash, region, send, shell, type Req } from './ui.ts';
12
+ import { appAllowed, appWorkspace, currentWorkspace, workspaceAdmin } from './workspaces.ts';
13
+
14
+ // (sprint 113, WS-3; migration 122) Builder → Team Development (APEX: Team Development): the current workspace's
15
+ // issues (a list with filters and saved views, and a board by label group or milestone that works without
16
+ // JavaScript), an issue's page (Markdown body, labels, milestone, assignees, application and page link, comments
17
+ // with @mentions and canned responses, history), labels in groups, milestones, templates, and the set-up wizard
18
+ // (src/team-samples.ts). Users' feedback and developer comments become issues (linked back). Every query is
19
+ // limited to the developer's current workspace; ids from the request are looked up within it.
20
+ // The data side (setup, counts, mentions, the workspace export's "team" part) is src/team.ts.
21
+
22
+ /** (a literal: ui.ts and locks.ts import this module's neighbours, so no top-level use of their constants) */
23
+ export const TEAM = '/builder/team';
24
+
25
+ type Tab = 'issues' | 'board' | 'labels' | 'milestones' | 'templates' | 'setup';
26
+ const isId = (v: unknown): v is string => typeof v === 'string' && /^\d{1,15}$/.test(v);
27
+ const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
28
+ const text = (v: unknown, max: number) => String(v ?? '').replace(/\r\n?/g, '\n').trim().slice(0, max);
29
+ const NAME40 = /^\S(.{0,38}\S)?$/;
30
+ /** Posted values of a multi-valued field (checkboxes): fastify's formbody gives a string or an array. */
31
+ const many = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : v === undefined || v === null || v === '' ? [] : [String(v)]);
32
+
33
+ /** A developer session with a current workspace, or null (answered). */
34
+ async function teamDev(req: Req, reply: FastifyReply): Promise<{ s: Session; ws: number } | null> {
35
+ const s = await developer(req, reply);
36
+ if (!s) return null;
37
+ const ws = currentWorkspace(s);
38
+ if (ws < 0) {
39
+ await send(reply.code(403), s, shell(s, 'Team Development', [['Team Development']],
40
+ html`<h1>Team Development</h1><div class="alert alert-info" role="status">You are not a developer of any workspace: Team Development belongs to a workspace.</div>`, 'team'));
41
+ return null;
42
+ }
43
+ return { s, ws };
44
+ }
45
+
46
+ interface Label { id: number; name: string; color: string; group_id: number | null; group_name: string | null; description: string | null; seq: number }
47
+ interface Milestone { id: number; name: string; description: string | null; due_date: string | null; status: string; open: number; closed: number }
48
+
49
+ async function lookups(ws: number) {
50
+ const [groups, labels, milestones, apps, devs] = await Promise.all([
51
+ owner.query<{ id: number; name: string; description: string | null; seq: number }>('select id, name, description, seq from meta.team_label_group where workspace_id = $1 order by seq, lower(name)', [ws]),
52
+ owner.query<Label>(`select l.id, l.name, l.color, l.group_id, g.name as group_name, l.description, l.seq from meta.team_label l left join meta.team_label_group g on g.id = l.group_id
53
+ where l.workspace_id = $1 order by g.seq nulls last, lower(g.name), l.seq, lower(l.name)`, [ws]),
54
+ owner.query<Milestone>(`select m.id, m.name, m.description, m.due_date::text, m.status,
55
+ (select count(*) from meta.team_issue i where i.milestone_id = m.id and i.status = 'open')::int as open,
56
+ (select count(*) from meta.team_issue i where i.milestone_id = m.id and i.status = 'closed')::int as closed
57
+ from meta.team_milestone m where m.workspace_id = $1 order by m.status = 'closed', m.due_date nulls last, lower(m.name)`, [ws]),
58
+ owner.query<{ id: number; name: string; alias: string }>('select id, name, alias from meta.app a where meta.app_workspace(a.id) = $1 order by lower(name)', [ws]),
59
+ workspaceDevelopers(ws),
60
+ ]);
61
+ return { groups: groups.rows, labels: labels.rows, milestones: milestones.rows, apps: apps.rows, devs };
62
+ }
63
+ type Lookups = Awaited<ReturnType<typeof lookups>>;
64
+
65
+ const chip = (l: { name: string; color: string }) => html`<span class="team-label team-label-${isLabelColor(l.color) ? l.color : 'gray'}">${l.name}</span>`;
66
+
67
+ function teamHeader(active: Tab, counts?: { open: number; mine: number; mentions: number }) {
68
+ const tab = (key: Tab, href: string, ic: string, label: string) =>
69
+ html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
70
+ return html`<div class="team-head"><h1>Team Development</h1>
71
+ ${counts ? html`<p class="muted small team-counts">${counts.open} open issue${counts.open === 1 ? '' : 's'} · <a href="${TEAM}?assignee=me">${counts.mine} assigned to you</a>${counts.mentions ? html` · <a href="${TEAM}#mentions">${counts.mentions} mention${counts.mentions === 1 ? '' : 's'} of you</a>` : ''}</p>` : ''}
72
+ <a class="btn btn-hot btn-sm" href="${TEAM}/issues/new">${icon('plus')} New issue</a></div>
73
+ <nav class="ide-tabs team-tabs u-mb1" aria-label="Team Development">
74
+ ${tab('issues', TEAM, 'list', 'Issues')}${tab('board', `${TEAM}/board`, 'kanban', 'Board')}${tab('labels', `${TEAM}/labels`, 'tag', 'Labels')}
75
+ ${tab('milestones', `${TEAM}/milestones`, 'flag', 'Milestones')}${tab('templates', `${TEAM}/templates`, 'note', 'Templates')}${tab('setup', `${TEAM}/setup`, 'settings', 'Set up')}
76
+ </nav>`;
77
+ }
78
+
79
+ const page = (s: Session, title: string, crumbs: [string, string?][], main: Raw) => shell(s, title, [['Team Development', TEAM], ...crumbs], main, 'team');
80
+
81
+ // ------------------------------------------------------------------ filters
82
+
83
+ interface Filters { status: 'open' | 'closed' | 'all'; label: string; milestone: string; assignee: string; app: string; page: string; q: string }
84
+ function parseFilters(q: Record<string, unknown>): Filters {
85
+ const s = (k: string) => (typeof q[k] === 'string' ? (q[k] as string).trim() : '');
86
+ return {
87
+ status: s('status') === 'closed' || s('status') === 'all' ? (s('status') as 'closed' | 'all') : 'open',
88
+ label: isId(s('label')) ? s('label') : '',
89
+ milestone: isId(s('milestone')) || s('milestone') === 'none' ? s('milestone') : '',
90
+ assignee: s('assignee').slice(0, 100),
91
+ app: /^\d{1,9}$/.test(s('app')) ? s('app') : '',
92
+ page: /^\d{1,9}$/.test(s('page')) ? s('page') : '',
93
+ q: s('q').slice(0, 100),
94
+ };
95
+ }
96
+ const filterQuery = (f: Filters, extra: Record<string, string> = {}) => {
97
+ const p = new URLSearchParams();
98
+ for (const [k, v] of Object.entries(f)) if (v && !(k === 'status' && v === 'open')) p.set(k, v);
99
+ for (const [k, v] of Object.entries(extra)) if (v) p.set(k, v);
100
+ return p.toString();
101
+ };
102
+
103
+ const ISSUE_COLS = `i.id, i.title, i.status, i.page_no, i.app_id, i.created_by, i.created_at, i.updated_at, i.closed_at, i.milestone_id, m.name as milestone,
104
+ a.name as app_name,
105
+ (select count(*) from meta.team_issue_comment c where c.issue_id = i.id)::int as comments,
106
+ coalesce((select json_agg(json_build_object('id', l.id, 'name', l.name, 'color', l.color, 'group_id', l.group_id) order by g.seq nulls last, l.seq, lower(l.name))
107
+ from meta.team_issue_label il join meta.team_label l on l.id = il.label_id left join meta.team_label_group g on g.id = l.group_id where il.issue_id = i.id), '[]') as labels,
108
+ coalesce((select array_agg(x.username order by x.username) from meta.team_issue_assignee x where x.issue_id = i.id), '{}') as assignees`;
109
+ const ISSUE_FROM = `meta.team_issue i left join meta.team_milestone m on m.id = i.milestone_id
110
+ left join meta.app a on a.id = i.app_id and meta.app_workspace(a.id) = i.workspace_id`;
111
+
112
+ interface IssueRow {
113
+ id: number; title: string; status: string; page_no: number | null; app_id: number | null; app_name: string | null; created_by: string; created_at: string; updated_at: string;
114
+ closed_at: string | null; milestone_id: number | null; milestone: string | null; comments: number; labels: { id: number; name: string; color: string; group_id: number | null }[]; assignees: string[];
115
+ }
116
+
117
+ async function findIssues(ws: number, f: Filters, me: string | null, limit = 500) {
118
+ const where = ['i.workspace_id = $1'];
119
+ const params: unknown[] = [ws];
120
+ const add = (sql: string, v: unknown) => {
121
+ params.push(v);
122
+ where.push(sql.replace('?', `$${params.length}`));
123
+ };
124
+ if (f.status !== 'all') add('i.status = ?', f.status);
125
+ if (f.label) add('exists (select 1 from meta.team_issue_label il where il.issue_id = i.id and il.label_id = ?)', f.label);
126
+ if (f.milestone === 'none') where.push('i.milestone_id is null');
127
+ else if (f.milestone) add('i.milestone_id = ?', f.milestone);
128
+ if (f.assignee === 'none') where.push('not exists (select 1 from meta.team_issue_assignee x where x.issue_id = i.id)');
129
+ else if (f.assignee) add('exists (select 1 from meta.team_issue_assignee x where x.issue_id = i.id and x.username = ?)', f.assignee === 'me' ? me : f.assignee);
130
+ if (f.app) add('i.app_id = ?', f.app);
131
+ if (f.page) add('i.page_no = ?', f.page);
132
+ if (f.q) {
133
+ params.push(`%${f.q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`, f.q.startsWith('#') ? f.q : `#${f.q}`);
134
+ where.push(`(i.title ilike $${params.length - 1} or i.body ilike $${params.length - 1} or ('#' || i.id) = $${params.length})`);
135
+ }
136
+ return (await owner.query<IssueRow>(`select ${ISSUE_COLS} from ${ISSUE_FROM} where ${where.join(' and ')} order by i.status = 'closed', i.updated_at desc, i.id desc limit ${limit}`, params)).rows;
137
+ }
138
+
139
+ function filterForm(f: Filters, lk: Lookups, action: string, hidden: Record<string, string> = {}) {
140
+ const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
141
+ const sel = (name: string, label: string, cur: string, options: Raw[]) =>
142
+ html`<div class="field"><label class="label" for="tf_${name}">${label}</label><select id="tf_${name}" name="${name}">${options}</select></div>`;
143
+ return html`<form method="get" action="${action}" class="team-filters" role="search" aria-label="Filter the issues">
144
+ ${Object.entries(hidden).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}
145
+ ${f.page ? html`<input type="hidden" name="page" value="${f.page}">` : ''}
146
+ <div class="field team-q"><label class="label" for="tf_q">Search</label><input id="tf_q" name="q" type="search" value="${f.q}" maxlength="100" placeholder="Title, text or #number"></div>
147
+ ${sel('status', 'Status', f.status, [opt('open', 'Open', f.status), opt('closed', 'Closed', f.status), opt('all', 'All', f.status)])}
148
+ ${sel('label', 'Label', f.label, [opt('', 'Any', f.label), ...lk.labels.map((l) => opt(String(l.id), l.group_name ? `${l.group_name}: ${l.name}` : l.name, f.label))])}
149
+ ${sel('milestone', 'Milestone', f.milestone, [opt('', 'Any', f.milestone), opt('none', 'None', f.milestone), ...lk.milestones.map((m) => opt(String(m.id), m.name, f.milestone))])}
150
+ ${sel('assignee', 'Assignee', f.assignee, [opt('', 'Anyone', f.assignee), opt('me', 'Me', f.assignee), opt('none', 'Nobody', f.assignee), ...lk.devs.map((d) => opt(d, d, f.assignee))])}
151
+ ${sel('app', 'Application', f.app, [opt('', 'Any', f.app), ...lk.apps.map((a) => opt(String(a.id), a.name, f.app))])}
152
+ <div class="buttons"><button class="btn btn-sm">${icon('filter')} Show</button>${filterQuery(f) ? html`<a class="btn btn-sm" href="${action}${Object.keys(hidden).length ? `?${new URLSearchParams(hidden)}` : ''}">Clear</a>` : ''}</div>
153
+ </form>`;
154
+ }
155
+
156
+ function issueCard(i: IssueRow, extra: Raw | '' = '') {
157
+ return html`<li class="team-issue${i.status === 'closed' ? ' is-closed' : ''}">
158
+ <div class="team-issue-title">${icon(i.status === 'closed' ? 'check-circle' : 'alert')}<a href="${TEAM}/issues/${i.id}">${i.title}</a> <span class="muted">#${i.id}</span></div>
159
+ ${i.labels.length ? html`<div class="team-labels">${i.labels.map(chip)}</div>` : ''}
160
+ <div class="team-issue-meta small muted">
161
+ ${i.status === 'closed' ? html`<span>closed ${ago(i.closed_at ?? i.updated_at)}</span>` : html`<span>opened ${ago(i.created_at)} by ${i.created_by}</span>`}
162
+ ${i.milestone ? html`<span>${icon('flag')}${i.milestone}</span>` : ''}
163
+ ${i.assignees.length ? html`<span>${icon('user')}${i.assignees.join(', ')}</span>` : ''}
164
+ ${i.app_name ? html`<span>${icon('grid')}${i.app_name}${i.page_no !== null ? ` · page ${i.page_no}` : ''}</span>` : ''}
165
+ ${i.comments ? html`<span>${icon('chat')}${i.comments}</span>` : ''}
166
+ </div>${extra}</li>`;
167
+ }
168
+
169
+ // ------------------------------------------------------------------ reading and validating an issue form
170
+
171
+ interface IssueInput { title: string; body: string; labels: string[]; milestone: string | null; assignees: string[]; app: number | null; page: number | null }
172
+ async function readIssue(b: Record<string, unknown>, ws: number, lk: Lookups): Promise<{ v: IssueInput; errors: string[] }> {
173
+ const errors: string[] = [];
174
+ const title = text(b.title, 1000).replace(/\s+/g, ' ');
175
+ if (!title) errors.push('An issue needs a title.');
176
+ if (title.length > 200) errors.push('The title is at most 200 characters.');
177
+ const body = String(b.body ?? '').replace(/\r\n?/g, '\n');
178
+ if (body.length > 20000) errors.push('The text is at most 20000 characters.');
179
+ const labelIds = new Set(lk.labels.map((l) => String(l.id)));
180
+ const labels = many(b.labels);
181
+ if (labels.some((l) => !labelIds.has(l))) errors.push('Unknown label.');
182
+ const milestone = String(b.milestone ?? '');
183
+ if (milestone && !lk.milestones.some((m) => String(m.id) === milestone)) errors.push('Unknown milestone.');
184
+ const assignees = many(b.assignees);
185
+ const devs = new Set(lk.devs);
186
+ if (assignees.some((a) => !devs.has(a))) errors.push('Assignees are developers of this workspace.');
187
+ const appRaw = String(b.app ?? '');
188
+ let app: number | null = null;
189
+ if (appRaw) {
190
+ if (!/^\d{1,9}$/.test(appRaw) || !lk.apps.some((a) => String(a.id) === appRaw) || (await appWorkspace(appRaw)) !== ws) errors.push('The application is not one of this workspace.');
191
+ else app = Number(appRaw);
192
+ }
193
+ const pageRaw = String(b.page ?? '').trim();
194
+ let pageNo: number | null = null;
195
+ if (pageRaw) {
196
+ if (!/^\d{1,9}$/.test(pageRaw)) errors.push('The page is a page number.');
197
+ else if (!app) errors.push('Choose the application of the page.');
198
+ else if (!(await owner.one('select 1 as ok from meta.page where app_id = $1 and page_no = $2', [app, pageRaw]))) errors.push(`The application has no page ${pageRaw}.`);
199
+ else pageNo = Number(pageRaw);
200
+ }
201
+ return { v: { title: title.slice(0, 200), body: body.slice(0, 20000), labels: [...new Set(labels)], milestone: milestone || null, assignees: [...new Set(assignees)], app, page: pageNo }, errors };
202
+ }
203
+
204
+ function issueFields(v: Partial<IssueInput>, lk: Lookups, idp = 'f') {
205
+ const labelSet = new Set(v.labels ?? []);
206
+ const groups = new Map<string, Label[]>();
207
+ for (const l of lk.labels) groups.set(l.group_name ?? 'Other labels', [...(groups.get(l.group_name ?? 'Other labels') ?? []), l]);
208
+ const asg = new Set(v.assignees ?? []);
209
+ return html`
210
+ <div class="field"><label class="label" for="${idp}_title">Title</label><input id="${idp}_title" name="title" maxlength="200" required value="${v.title ?? ''}"></div>
211
+ <div class="field"><label class="label" for="${idp}_body">Description</label>
212
+ <textarea id="${idp}_body" name="body" rows="10" maxlength="20000">${v.body ?? ''}</textarea>
213
+ <small class="help">Markdown: headings, lists, <code>code</code>, links. Mention a developer with @name: they see it under Team Development.</small></div>
214
+ <div class="form-grid">
215
+ <div class="field"><label class="label" for="${idp}_milestone">Milestone</label><select id="${idp}_milestone" name="milestone">
216
+ <option value="">None</option>${lk.milestones.map((m) => html`<option value="${m.id}"${String(m.id) === String(v.milestone ?? '') ? raw(' selected') : ''}>${m.name}${m.status === 'closed' ? ' (closed)' : ''}</option>`)}</select></div>
217
+ <div class="field"><label class="label" for="${idp}_app">Application</label><select id="${idp}_app" name="app">
218
+ <option value="">None</option>${lk.apps.map((a) => html`<option value="${a.id}"${a.id === v.app ? raw(' selected') : ''}>${a.name}</option>`)}</select></div>
219
+ <div class="field"><label class="label" for="${idp}_page">Page number</label><input id="${idp}_page" name="page" type="number" min="0" step="1" inputmode="numeric" value="${v.page ?? ''}">
220
+ <small class="help">A page of the application.</small></div>
221
+ </div>
222
+ ${lk.labels.length ? html`<fieldset class="team-fieldset"><legend>Labels</legend>${[...groups].map(([g, ls]) => html`<div class="team-check-group"><span class="small muted">${g}</span>
223
+ ${ls.map((l) => html`<label class="check"><input type="checkbox" name="labels" value="${l.id}"${labelSet.has(String(l.id)) ? raw(' checked') : ''}> ${chip(l)}</label>`)}</div>`)}</fieldset>`
224
+ : html`<p class="muted small">No labels yet: <a href="${TEAM}/setup">set up Team Development</a> or <a href="${TEAM}/labels">add labels</a>.</p>`}
225
+ <fieldset class="team-fieldset"><legend>Assignees</legend><div class="team-check-group">
226
+ ${lk.devs.map((d) => html`<label class="check"><input type="checkbox" name="assignees" value="${d}"${asg.has(d) ? raw(' checked') : ''}> ${d}</label>`)}</div></fieldset>`;
227
+ }
228
+
229
+ /** Store an issue's labels and assignees; returns the history lines of what changed. */
230
+ async function setLinks(c: (sql: string, params?: unknown[]) => Promise<unknown>, id: number, v: IssueInput, lk: Lookups, before: { labels: string[]; assignees: string[] }) {
231
+ const lines: [string, string][] = [];
232
+ const name = (lid: string) => lk.labels.find((l) => String(l.id) === lid)?.name ?? lid;
233
+ const addL = v.labels.filter((x) => !before.labels.includes(x));
234
+ const delL = before.labels.filter((x) => !v.labels.includes(x));
235
+ if (delL.length) await c('delete from meta.team_issue_label where issue_id = $1 and label_id = any($2::bigint[])', [id, delL]);
236
+ for (const l of addL) await c('insert into meta.team_issue_label (issue_id, label_id) values ($1, $2) on conflict do nothing', [id, l]);
237
+ if (addL.length || delL.length) lines.push(['labels', [...addL.map((x) => `+${name(x)}`), ...delL.map((x) => `-${name(x)}`)].join(' ')]);
238
+ const addA = v.assignees.filter((x) => !before.assignees.includes(x));
239
+ const delA = before.assignees.filter((x) => !v.assignees.includes(x));
240
+ if (delA.length) await c('delete from meta.team_issue_assignee where issue_id = $1 and username = any($2::text[])', [id, delA]);
241
+ for (const a of addA) await c('insert into meta.team_issue_assignee (issue_id, username) values ($1, $2) on conflict do nothing', [id, a]);
242
+ if (addA.length || delA.length) lines.push(['assignees', [...addA.map((x) => `+${x}`), ...delA.map((x) => `-${x}`)].join(' ')]);
243
+ return lines;
244
+ }
245
+
246
+ const issueOf = async (ws: number, id: string) =>
247
+ isId(id) ? owner.one(`select i.*, m.name as milestone from meta.team_issue i left join meta.team_milestone m on m.id = i.milestone_id where i.id = $1 and i.workspace_id = $2`, [id, ws]) : undefined;
248
+
249
+ const EVENT_TEXT: Record<string, string> = {
250
+ created: 'created the issue', closed: 'closed the issue', reopened: 'reopened the issue', title: 'changed the title', body: 'edited the description',
251
+ labels: 'changed the labels', assignees: 'changed the assignees', milestone: 'set the milestone', link: 'linked', commented: 'commented',
252
+ };
253
+
254
+ export async function teamRoutes(app: FastifyInstance) {
255
+ // ------------------------------------------------------------------ the issue list
256
+ app.get(TEAM, async (req: Req, reply) => {
257
+ const t = await teamDev(req, reply);
258
+ if (!t) return;
259
+ const { s, ws } = t;
260
+ const f = parseFilters(req.query ?? {});
261
+ const [lk, counts, rows, views, mentions] = await Promise.all([
262
+ lookups(ws), teamCounts(ws, s.username), findIssues(ws, f, s.username),
263
+ owner.query('select id, name, query from meta.team_view where workspace_id = $1 and username = $2 order by lower(name)', [ws, s.username]),
264
+ owner.query(`select m.id, m.issue_id, m.by_user, m.created_at, i.title from meta.team_mention m join meta.team_issue i on i.id = m.issue_id
265
+ where i.workspace_id = $1 and m.username = $2 and m.seen_at is null order by m.created_at desc limit 20`, [ws, s.username]),
266
+ ]);
267
+ const current = filterQuery(f);
268
+ const main = html`${teamHeader('issues', counts)}
269
+ ${!counts.set_up ? html`<div class="alert alert-info" role="status">${icon('lightbulb')} <span>New here? <a href="${TEAM}/setup">Set up Team Development</a>: sample labels (Bug, Feature, Task; priorities), templates and milestones.</span></div>` : ''}
270
+ <div class="team-layout">
271
+ <div class="team-main">
272
+ ${filterForm(f, lk, TEAM)}
273
+ ${f.app && f.page ? html`<p class="small muted">Issues of page ${f.page} of ${lk.apps.find((a) => String(a.id) === f.app)?.name ?? 'the application'}.</p>` : ''}
274
+ <p class="small muted" role="status">${rows.length} issue${rows.length === 1 ? '' : 's'}${rows.length === 500 ? ' (the first 500)' : ''}.</p>
275
+ ${rows.length ? html`<ul class="team-issues">${rows.map((i) => issueCard(i))}</ul>` : html`<p class="muted">No issues${current ? ' with these filters' : ' yet'}.</p>`}
276
+ </div>
277
+ <aside class="team-side">
278
+ <section id="mentions" aria-labelledby="team-mentions-h"><h2 id="team-mentions-h">Mentions of you</h2>
279
+ ${mentions.rows.length ? html`<ul class="team-mentions">${mentions.rows.map((m) => html`<li><a href="${TEAM}/issues/${m.issue_id}">${m.title}</a> <span class="small muted">${m.by_user ?? ''} · ${ago(m.created_at)}</span></li>`)}</ul>
280
+ <form method="post" action="${TEAM}/mentions/seen">${csrf(s)}<button class="btn btn-sm">${icon('check')} Mark all as seen</button></form>`
281
+ : html`<p class="muted small">Nobody mentioned you in an unseen comment.</p>`}</section>
282
+ <section aria-labelledby="team-views-h"><h2 id="team-views-h">Saved views</h2>
283
+ ${views.rows.length ? html`<ul class="team-views">${views.rows.map((v) => html`<li><a href="${TEAM}${v.query ? `?${v.query}` : ''}"${v.query === current ? raw(' aria-current="true"') : ''}>${v.name}</a>
284
+ <form method="post" action="${TEAM}/views/${v.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" title="Delete the view ${v.name}" data-confirm="Delete the view ${v.name}?">${icon('trash')}<span class="sr-only">Delete ${v.name}</span></button></form></li>`)}</ul>`
285
+ : html`<p class="muted small">Save the filters you use often.</p>`}
286
+ <form method="post" action="${TEAM}/views" class="team-save-view">${csrf(s)}<input type="hidden" name="query" value="${current}">
287
+ <label class="sr-only" for="tv_name">Name of the view</label><input id="tv_name" name="name" maxlength="40" required placeholder="Name of this view">
288
+ <button class="btn btn-sm">${icon('bookmark')} Save view</button></form></section>
289
+ <section aria-labelledby="team-quick-h"><h2 id="team-quick-h">Quick filters</h2><ul class="ab-links ab-tasks">
290
+ <li><a href="${TEAM}?assignee=me">Assigned to me</a></li>
291
+ <li><a href="${TEAM}?assignee=none">Not assigned</a></li>
292
+ <li><a href="${TEAM}?status=closed">Closed</a></li>
293
+ <li><a href="${TEAM}/board">Board by label</a></li>
294
+ <li><a href="${TEAM}/board?by=milestone">Board by milestone</a></li></ul></section>
295
+ </aside>
296
+ </div>`;
297
+ return send(reply, s, page(s, 'Team Development', [], main));
298
+ });
299
+
300
+ // ------------------------------------------------------------------ the board
301
+ app.get(`${TEAM}/board`, async (req: Req, reply) => {
302
+ const t = await teamDev(req, reply);
303
+ if (!t) return;
304
+ const { s, ws } = t;
305
+ const f = parseFilters(req.query ?? {});
306
+ const by = req.query?.by === 'milestone' ? 'milestone' : 'label';
307
+ const [lk, counts, rows] = await Promise.all([lookups(ws), teamCounts(ws, s.username), findIssues(ws, f, s.username)]);
308
+ // by label: the labels of one group (or the labels without a group: "0")
309
+ const groupIds = [...lk.groups.map((g) => String(g.id)), ...(lk.labels.some((l) => l.group_id === null) ? ['0'] : [])];
310
+ const group = by === 'label' ? (groupIds.includes(String(req.query?.group ?? '')) ? String(req.query.group) : groupIds[0] ?? '0') : '';
311
+ type Col = { key: string; title: string; chip?: Raw; items: IssueRow[] };
312
+ let cols: Col[];
313
+ if (by === 'milestone') {
314
+ const ms = lk.milestones.filter((m) => m.status === 'open' || rows.some((r) => String(r.milestone_id) === String(m.id)));
315
+ cols = [...ms.map((m) => ({ key: String(m.id), title: m.name, items: rows.filter((r) => String(r.milestone_id) === String(m.id)) })),
316
+ { key: '', title: 'No milestone', items: rows.filter((r) => r.milestone_id === null) }];
317
+ } else {
318
+ const ls = lk.labels.filter((l) => String(l.group_id ?? 0) === group);
319
+ // (ids: bigint columns come as strings, the json_agg ones as numbers)
320
+ const ids = new Set(ls.map((l) => String(l.id)));
321
+ cols = [...ls.map((l) => ({ key: String(l.id), title: l.name, chip: chip(l), items: rows.filter((r) => r.labels.some((x) => String(x.id) === String(l.id))) })),
322
+ { key: '', title: 'None of these', items: rows.filter((r) => !r.labels.some((x) => ids.has(String(x.id)))) }];
323
+ }
324
+ const back = `${TEAM}/board?${filterQuery(f, { by: by === 'milestone' ? 'milestone' : '', group: by === 'label' ? group : '' })}`;
325
+ const move = (i: IssueRow, from: string) => {
326
+ const others = cols.filter((c) => c.key !== from);
327
+ return html`<form method="post" action="${TEAM}/issues/${i.id}/move" class="team-move">${csrf(s)}
328
+ <input type="hidden" name="by" value="${by}"><input type="hidden" name="group" value="${group}"><input type="hidden" name="from" value="${from}"><input type="hidden" name="back" value="${back}">
329
+ <label class="sr-only" for="mv_${i.id}_${from}">Move #${i.id} to</label>
330
+ <select id="mv_${i.id}_${from}" name="to">${others.map((c) => html`<option value="${c.key}">${c.title}</option>`)}</select>
331
+ <button class="btn btn-sm">Move</button></form>`;
332
+ };
333
+ const groupNav = by === 'label' && groupIds.length > 1
334
+ ? html`<nav class="seg team-groups" aria-label="Label group">${groupIds.map((g) => html`<a href="${TEAM}/board?${filterQuery(f, { group: g })}"${g === group ? raw(' aria-current="true"') : ''}>${g === '0' ? 'Other labels' : lk.groups.find((x) => String(x.id) === g)?.name}</a>`)}</nav>` : '';
335
+ const main = html`${teamHeader('board', counts)}
336
+ <div class="team-board-bar">
337
+ <nav class="seg" aria-label="Group the board by"><a href="${TEAM}/board?${filterQuery(f)}"${by === 'label' ? raw(' aria-current="true"') : ''}>${icon('tag')} Label</a><a href="${TEAM}/board?${filterQuery(f, { by: 'milestone' })}"${by === 'milestone' ? raw(' aria-current="true"') : ''}>${icon('flag')} Milestone</a></nav>
338
+ ${groupNav}
339
+ </div>
340
+ <details class="team-board-filters"><summary>${icon('filter')} Filters${filterQuery(f) ? ' (on)' : ''}</summary>${filterForm(f, lk, `${TEAM}/board`, { ...(by === 'milestone' ? { by } : {}), ...(by === 'label' ? { group } : {}) })}</details>
341
+ ${by === 'label' && !lk.labels.length ? html`<p class="muted">No labels yet: <a href="${TEAM}/setup">set up Team Development</a>.</p>` : ''}
342
+ <div class="team-board">${cols.map((c) => html`<section class="team-col" aria-label="${c.title}">
343
+ <h2 class="team-col-head">${c.chip ?? c.title} <span class="muted small">${c.items.length}</span></h2>
344
+ ${c.items.length ? html`<ul class="team-issues">${c.items.map((i) => issueCard(i, move(i, c.key)))}</ul>` : html`<p class="muted small">No issues.</p>`}
345
+ </section>`)}</div>`;
346
+ return send(reply, s, page(s, 'Board', [['Board']], main));
347
+ });
348
+
349
+ // move a card: by milestone sets the milestone; by label replaces the issue's labels of that group with the target label
350
+ app.post(`${TEAM}/issues/:id/move`, async (req: Req, reply) => {
351
+ const t = await teamDev(req, reply);
352
+ if (!t) return;
353
+ const { s, ws } = t;
354
+ const i = await issueOf(ws, req.params.id);
355
+ if (!i) return reply.code(404).send('Not found');
356
+ const b = req.body ?? {};
357
+ const to = String(b.to ?? '');
358
+ const lk = await lookups(ws);
359
+ const backTo = typeof b.back === 'string' && b.back.startsWith(`${TEAM}/board`) && !/[\r\n]/.test(b.back) ? b.back : `${TEAM}/board`;
360
+ if (b.by === 'milestone') {
361
+ const m = to ? lk.milestones.find((x) => String(x.id) === to) : null;
362
+ if (to && !m) return reply.code(400).send('Unknown milestone');
363
+ await owner.tx(async (c) => {
364
+ await c.query('update meta.team_issue set milestone_id = $2, updated_at = now() where id = $1', [i.id, m?.id ?? null]);
365
+ await logIssue(c, i.id, s.username, 'milestone', m ? m.name : 'none');
366
+ });
367
+ } else {
368
+ const group = String(b.group ?? '');
369
+ const inGroup = lk.labels.filter((l) => String(l.group_id ?? 0) === group);
370
+ const target = to ? inGroup.find((l) => String(l.id) === to) : null;
371
+ if (!inGroup.length || (to && !target)) return reply.code(400).send('Unknown label');
372
+ await owner.tx(async (c) => {
373
+ const del = (await c.query('delete from meta.team_issue_label where issue_id = $1 and label_id = any($2::bigint[]) and label_id <> $3 returning label_id',
374
+ [i.id, inGroup.map((l) => l.id), target?.id ?? 0])).rows.map((r) => lk.labels.find((l) => Number(l.id) === Number(r.label_id))?.name);
375
+ const add = target ? (await c.query('insert into meta.team_issue_label (issue_id, label_id) values ($1, $2) on conflict do nothing', [i.id, target.id])).rowCount : 0;
376
+ await c.query('update meta.team_issue set updated_at = now() where id = $1', [i.id]);
377
+ if (del.length || add) await logIssue(c, i.id, s.username, 'labels', [...(add ? [`+${target!.name}`] : []), ...del.map((x) => `-${x}`)].join(' '));
378
+ });
379
+ }
380
+ flash(s, `#${i.id} moved.`);
381
+ return back(reply, s, backTo);
382
+ });
383
+
384
+ // ------------------------------------------------------------------ a new issue
385
+ app.get(`${TEAM}/issues/new`, async (req: Req, reply) => {
386
+ const t = await teamDev(req, reply);
387
+ if (!t) return;
388
+ const { s, ws } = t;
389
+ const lk = await lookups(ws);
390
+ const templates = (await owner.query(`select id, name, title, body, labels from meta.team_template where workspace_id = $1 and kind = 'issue' order by seq, lower(name)`, [ws])).rows;
391
+ const tpl = isId(req.query?.template) ? templates.find((x) => String(x.id) === req.query.template) : undefined;
392
+ const appQ = lk.apps.find((a) => String(a.id) === req.query?.app);
393
+ const v: Partial<IssueInput> = {
394
+ title: tpl?.title ?? '', body: tpl?.body ?? '',
395
+ labels: tpl ? lk.labels.filter((l) => (tpl.labels as string[]).some((n) => n.toLowerCase() === l.name.toLowerCase())).map((l) => String(l.id)) : [],
396
+ app: appQ?.id ?? null, page: appQ && /^\d{1,9}$/.test(req.query?.page ?? '') ? Number(req.query.page) : null,
397
+ };
398
+ const main = html`${teamHeader('issues')}
399
+ ${templates.length ? html`<form method="get" class="team-template-pick">${appQ ? html`<input type="hidden" name="app" value="${appQ.id}">` : ''}${v.page !== null && v.page !== undefined ? html`<input type="hidden" name="page" value="${v.page}">` : ''}
400
+ <div class="field"><label class="label" for="f_template">Start from a template</label><select id="f_template" name="template" data-autosubmit>
401
+ <option value="">Blank issue</option>${templates.map((x) => html`<option value="${x.id}"${x.id === tpl?.id ? raw(' selected') : ''}>${x.name}</option>`)}</select></div>
402
+ <button class="btn btn-sm">Use template</button></form>` : ''}
403
+ ${region('New issue', html`<form method="post" action="${TEAM}/issues">${csrf(s)}${issueFields(v, lk)}
404
+ <div class="buttons"><button class="btn btn-hot">Create issue</button><a class="btn" href="${TEAM}">Cancel</a></div></form>`)}`;
405
+ return send(reply, s, page(s, 'New issue', [['New issue']], main));
406
+ });
407
+
408
+ app.post(`${TEAM}/issues`, async (req: Req, reply) => {
409
+ const t = await teamDev(req, reply);
410
+ if (!t) return;
411
+ const { s, ws } = t;
412
+ const lk = await lookups(ws);
413
+ const { v, errors } = await readIssue(req.body ?? {}, ws, lk);
414
+ if (errors.length) {
415
+ flash(s, errors.join(' '), 'error');
416
+ return back(reply, s, `${TEAM}/issues/new${v.app ? `?app=${v.app}${v.page !== null ? `&page=${v.page}` : ''}` : ''}`);
417
+ }
418
+ const id = await owner.tx(async (c) => {
419
+ const id = Number((await c.query(`insert into meta.team_issue (workspace_id, title, body, milestone_id, app_id, page_no, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
420
+ [ws, v.title, v.body, v.milestone, v.app, v.page, s.username])).rows[0].id);
421
+ await setLinks((sql, p) => c.query(sql, p), id, v, lk, { labels: [], assignees: [] });
422
+ await logIssue(c, id, s.username, 'created');
423
+ await recordMentions(c, ws, id, null, v.body, s.username);
424
+ return id;
425
+ });
426
+ flash(s, `Issue #${id} created.`);
427
+ return back(reply, s, `${TEAM}/issues/${id}`);
428
+ });
429
+
430
+ // ------------------------------------------------------------------ an issue
431
+ app.get(`${TEAM}/issues/:id`, async (req: Req, reply) => {
432
+ const t = await teamDev(req, reply);
433
+ if (!t) return;
434
+ const { s, ws } = t;
435
+ const i = await issueOf(ws, req.params.id);
436
+ if (!i) return reply.code(404).send('Not found');
437
+ const [lk, labels, assignees, comments, events, responses, admin] = await Promise.all([
438
+ lookups(ws),
439
+ owner.query('select l.id, l.name, l.color from meta.team_issue_label il join meta.team_label l on l.id = il.label_id where il.issue_id = $1 order by lower(l.name)', [i.id]),
440
+ owner.query('select username from meta.team_issue_assignee where issue_id = $1 order by username', [i.id]),
441
+ owner.query('select id, author, body, created_at from meta.team_issue_comment where issue_id = $1 order by created_at, id', [i.id]),
442
+ owner.query(`select at, username, action, detail from meta.team_issue_event where issue_id = $1 and action <> 'commented' order by at, id`, [i.id]),
443
+ owner.query(`select id, name, body from meta.team_template where workspace_id = $1 and kind = 'response' order by seq, lower(name)`, [ws]),
444
+ workspaceAdmin(s.username, ws),
445
+ ]);
446
+ // the issue's own mentions of this developer are seen now
447
+ await owner.query('update meta.team_mention set seen_at = now() where issue_id = $1 and username = $2 and seen_at is null', [i.id, s.username]);
448
+ const appRow = i.app_id ? lk.apps.find((a) => a.id === i.app_id) : undefined;
449
+ const source = i.feedback_id
450
+ ? await owner.one('select f.id, f.app_id, f.username, a.name from meta.feedback f join meta.app a on a.id = f.app_id where f.id = $1 and meta.app_workspace(f.app_id) = $2', [i.feedback_id, ws])
451
+ : null;
452
+ const comment = i.dev_comment_id
453
+ ? await owner.one('select c.id, c.app_id, c.page_no, c.author, a.name from meta.dev_comment c join meta.app a on a.id = c.app_id where c.id = $1 and meta.app_workspace(c.app_id) = $2', [i.dev_comment_id, ws])
454
+ : null;
455
+ const response = isId(req.query?.response) ? responses.rows.find((r) => String(r.id) === req.query.response) : undefined;
456
+ const timeline = [
457
+ ...comments.rows.map((c) => ({ at: new Date(c.created_at).getTime(), r: html`<li class="team-comment" id="comment-${c.id}">
458
+ <div class="team-comment-head"><b>${c.author}</b> <span class="muted small">${when(c.created_at)}</span></div>
459
+ <div class="rich-text team-md">${raw(issueHtml(c.body, lk.devs))}</div></li>` })),
460
+ ...events.rows.map((e) => ({ at: new Date(e.at).getTime(), r: html`<li class="team-event small muted">${icon('history')}<span><b>${e.username ?? 'someone'}</b> ${EVENT_TEXT[e.action] ?? e.action}${e.detail ? html`: ${e.detail}` : ''} · ${when(e.at)}</span></li>` })),
461
+ ].sort((a, b) => a.at - b.at);
462
+ const v: IssueInput = { title: i.title, body: i.body, labels: labels.rows.map((l) => String(l.id)), milestone: i.milestone_id ? String(i.milestone_id) : null,
463
+ assignees: assignees.rows.map((a) => a.username), app: appRow?.id ?? null, page: appRow ? i.page_no : null };
464
+ const main = html`${teamHeader('issues')}
465
+ <article class="team-issue-page">
466
+ <header class="team-issue-header">
467
+ <h2 class="team-issue-h">${i.title} <span class="muted">#${i.id}</span></h2>
468
+ <p><span class="team-status team-status-${i.status === 'closed' ? 'closed' : 'open'}">${i.status === 'closed' ? 'Closed' : 'Open'}</span>
469
+ <span class="muted small">opened by ${i.created_by}, ${when(i.created_at)}${i.closed_at ? `; closed by ${i.closed_by ?? '?'}, ${when(i.closed_at)}` : ''}</span></p>
470
+ </header>
471
+ <div class="team-layout">
472
+ <div class="team-main">
473
+ <div class="rich-text team-md team-body">${i.body.trim() ? raw(issueHtml(i.body, lk.devs)) : html`<p class="muted">No description.</p>`}</div>
474
+ <h3>Activity</h3>
475
+ <ul class="team-timeline">${timeline.map((x) => x.r)}</ul>
476
+ <form method="get" class="team-response-pick">${responses.rows.length ? html`
477
+ <div class="field"><label class="label" for="f_response">Canned response</label><select id="f_response" name="response" data-autosubmit>
478
+ <option value="">None</option>${responses.rows.map((r) => html`<option value="${r.id}"${r.id === response?.id ? raw(' selected') : ''}>${r.name}</option>`)}</select></div>
479
+ <button class="btn btn-sm">Insert</button>` : ''}</form>
480
+ <form method="post" action="${TEAM}/issues/${i.id}/comments" id="add-comment">${csrf(s)}
481
+ <div class="field"><label class="label" for="f_comment">Add a comment</label>
482
+ <textarea id="f_comment" name="body" rows="4" maxlength="20000" required>${response?.body ?? ''}</textarea>
483
+ <small class="help">Markdown; @name mentions a developer of this workspace.</small></div>
484
+ <div class="buttons"><button class="btn btn-hot">Comment</button>
485
+ <button class="btn" name="and" value="${i.status === 'closed' ? 'reopen' : 'close'}">${i.status === 'closed' ? 'Comment and reopen' : 'Comment and close'}</button></div></form>
486
+ </div>
487
+ <aside class="team-side">
488
+ <section><h2>Status</h2><form method="post" action="${TEAM}/issues/${i.id}/status">${csrf(s)}
489
+ <button class="btn btn-sm" name="status" value="${i.status === 'closed' ? 'open' : 'closed'}">${icon(i.status === 'closed' ? 'refresh' : 'check-circle')} ${i.status === 'closed' ? 'Reopen issue' : 'Close issue'}</button></form></section>
490
+ <section><h2>Labels</h2>${labels.rows.length ? html`<div class="team-labels">${labels.rows.map(chip)}</div>` : html`<p class="muted small">None</p>`}</section>
491
+ <section><h2>Milestone</h2><p>${i.milestone ? html`<a href="${TEAM}?milestone=${i.milestone_id}">${i.milestone}</a>` : html`<span class="muted small">None</span>`}</p></section>
492
+ <section><h2>Assignees</h2><p>${assignees.rows.length ? assignees.rows.map((a) => a.username).join(', ') : html`<span class="muted small">Nobody</span>`}</p></section>
493
+ <section><h2>Application</h2><p>${appRow
494
+ ? html`<a href="${BASE}/apps/${appRow.id}">${appRow.name}</a>${i.page_no !== null ? html` · <a href="${designerUrl(appRow.id, i.page_no)}">page ${i.page_no}</a>` : ''}`
495
+ : html`<span class="muted small">None</span>`}</p></section>
496
+ ${source ? html`<section><h2>From feedback</h2><p><a href="${BASE}/apps/${source.app_id}/feedback#feedback-${source.id}">Feedback of ${source.username}</a> <span class="small muted">(${source.name})</span></p></section>` : ''}
497
+ ${comment ? html`<section><h2>From a developer comment</h2><p><a href="${comment.page_no ? designerUrl(comment.app_id, comment.page_no) : `${BASE}/apps/${comment.app_id}#app-lock`}">${comment.author}'s comment</a> <span class="small muted">(${comment.name}${comment.page_no ? `, page ${comment.page_no}` : ''})</span></p></section>` : ''}
498
+ ${i.created_by === s.username || admin ? html`<section><h2>Delete</h2><form method="post" action="${TEAM}/issues/${i.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete issue #${i.id} with its comments and history?">${icon('trash')} Delete issue</button></form></section>` : ''}
499
+ </aside>
500
+ </div>
501
+ <details class="team-edit" id="edit"><summary class="btn btn-sm">${icon('edit')} Edit the issue</summary>
502
+ <form method="post" action="${TEAM}/issues/${i.id}">${csrf(s)}${issueFields(v, lk, 'e')}
503
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form></details>
504
+ </article>`;
505
+ return send(reply, s, page(s, `#${i.id} ${i.title}`, [[`#${i.id}`]], main));
506
+ });
507
+
508
+ app.post(`${TEAM}/issues/:id`, async (req: Req, reply) => {
509
+ const t = await teamDev(req, reply);
510
+ if (!t) return;
511
+ const { s, ws } = t;
512
+ const i = await issueOf(ws, req.params.id);
513
+ if (!i) return reply.code(404).send('Not found');
514
+ const lk = await lookups(ws);
515
+ const { v, errors } = await readIssue(req.body ?? {}, ws, lk);
516
+ if (errors.length) {
517
+ flash(s, errors.join(' '), 'error');
518
+ return back(reply, s, `${TEAM}/issues/${i.id}#edit`);
519
+ }
520
+ await owner.tx(async (c) => {
521
+ const before = {
522
+ labels: (await c.query('select label_id::text as id from meta.team_issue_label where issue_id = $1', [i.id])).rows.map((r) => r.id),
523
+ assignees: (await c.query('select username from meta.team_issue_assignee where issue_id = $1', [i.id])).rows.map((r) => r.username),
524
+ };
525
+ await c.query('update meta.team_issue set title = $2, body = $3, milestone_id = $4, app_id = $5, page_no = $6, updated_at = now() where id = $1',
526
+ [i.id, v.title, v.body, v.milestone, v.app, v.page]);
527
+ if (v.title !== i.title) await logIssue(c, i.id, s.username, 'title', `${i.title} → ${v.title}`);
528
+ if (v.body !== i.body) await logIssue(c, i.id, s.username, 'body');
529
+ if (String(v.milestone ?? '') !== String(i.milestone_id ?? '')) await logIssue(c, i.id, s.username, 'milestone', lk.milestones.find((m) => String(m.id) === v.milestone)?.name ?? 'none');
530
+ if (v.app !== i.app_id || v.page !== i.page_no)
531
+ await logIssue(c, i.id, s.username, 'link', v.app ? `${lk.apps.find((a) => a.id === v.app)?.name}${v.page !== null ? `, page ${v.page}` : ''}` : 'no application');
532
+ for (const [action, detail] of await setLinks((sql, p) => c.query(sql, p), i.id, v, lk, before)) await logIssue(c, i.id, s.username, action, detail);
533
+ await recordMentions(c, ws, i.id, null, v.body, s.username);
534
+ });
535
+ flash(s, 'Issue saved.');
536
+ return back(reply, s, `${TEAM}/issues/${i.id}`);
537
+ });
538
+
539
+ const setStatus = async (c: Client, id: number, status: 'open' | 'closed', by: string | null) => {
540
+ await c.query(`update meta.team_issue set status = $2, updated_at = now(), closed_at = case when $2 = 'closed' then now() end, closed_by = case when $2 = 'closed' then $3 end where id = $1`, [id, status, by]);
541
+ await logIssue(c, id, by, status === 'closed' ? 'closed' : 'reopened');
542
+ };
543
+
544
+ app.post(`${TEAM}/issues/:id/status`, async (req: Req, reply) => {
545
+ const t = await teamDev(req, reply);
546
+ if (!t) return;
547
+ const { s, ws } = t;
548
+ const i = await issueOf(ws, req.params.id);
549
+ if (!i) return reply.code(404).send('Not found');
550
+ const status = req.body?.status;
551
+ if (status !== 'open' && status !== 'closed') return reply.code(400).send('Unknown status');
552
+ if (status !== i.status) await owner.tx((c) => setStatus(c, i.id, status, s.username));
553
+ flash(s, status === 'closed' ? `Issue #${i.id} closed.` : `Issue #${i.id} reopened.`);
554
+ return back(reply, s, `${TEAM}/issues/${i.id}`);
555
+ });
556
+
557
+ app.post(`${TEAM}/issues/:id/comments`, async (req: Req, reply) => {
558
+ const t = await teamDev(req, reply);
559
+ if (!t) return;
560
+ const { s, ws } = t;
561
+ const i = await issueOf(ws, req.params.id);
562
+ if (!i) return reply.code(404).send('Not found');
563
+ const body = String(req.body?.body ?? '').replace(/\r\n?/g, '\n');
564
+ if (!body.trim() || body.length > 20000) {
565
+ flash(s, 'A comment has 1 to 20000 characters.', 'error');
566
+ return back(reply, s, `${TEAM}/issues/${i.id}#add-comment`);
567
+ }
568
+ const and = req.body?.and;
569
+ const cid = await owner.tx(async (c) => {
570
+ const cid = (await c.query('insert into meta.team_issue_comment (issue_id, author, body) values ($1, $2, $3) returning id', [i.id, s.username, body])).rows[0].id;
571
+ await c.query('update meta.team_issue set updated_at = now() where id = $1', [i.id]);
572
+ await logIssue(c, i.id, s.username, 'commented');
573
+ await recordMentions(c, ws, i.id, cid, body, s.username);
574
+ if (and === 'close' && i.status === 'open') await setStatus(c, i.id, 'closed', s.username);
575
+ if (and === 'reopen' && i.status === 'closed') await setStatus(c, i.id, 'open', s.username);
576
+ return cid;
577
+ });
578
+ flash(s, 'Comment added.');
579
+ return back(reply, s, `${TEAM}/issues/${i.id}#comment-${cid}`);
580
+ });
581
+
582
+ app.post(`${TEAM}/issues/:id/delete`, async (req: Req, reply) => {
583
+ const t = await teamDev(req, reply);
584
+ if (!t) return;
585
+ const { s, ws } = t;
586
+ const i = await issueOf(ws, req.params.id);
587
+ if (!i) return reply.code(404).send('Not found');
588
+ if (i.created_by !== s.username && !(await workspaceAdmin(s.username, ws))) return reply.code(403).send('Only its author or a workspace administrator deletes an issue.');
589
+ await owner.query('delete from meta.team_issue where id = $1', [i.id]);
590
+ await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `team: issue ${i.id} deleted` });
591
+ flash(s, `Issue #${i.id} deleted.`);
592
+ return back(reply, s, TEAM);
593
+ });
594
+
595
+ // ------------------------------------------------------------------ saved views and mentions
596
+ app.post(`${TEAM}/views`, async (req: Req, reply) => {
597
+ const t = await teamDev(req, reply);
598
+ if (!t) return;
599
+ const { s, ws } = t;
600
+ const name = text(req.body?.name, 100);
601
+ if (!NAME40.test(name)) {
602
+ flash(s, 'A view has a name of 1 to 40 characters.', 'error');
603
+ return back(reply, s, TEAM);
604
+ }
605
+ // only the known filters, re-checked
606
+ const query = filterQuery(parseFilters(Object.fromEntries(new URLSearchParams(String(req.body?.query ?? '').slice(0, 1000)))));
607
+ await owner.query(`insert into meta.team_view (workspace_id, username, name, query) values ($1, $2, $3, $4)
608
+ on conflict (workspace_id, username, lower(name)) do update set query = excluded.query`, [ws, s.username, name, query]);
609
+ flash(s, `View ${name} saved.`);
610
+ return back(reply, s, `${TEAM}${query ? `?${query}` : ''}`);
611
+ });
612
+
613
+ app.post(`${TEAM}/views/:id/delete`, async (req: Req, reply) => {
614
+ const t = await teamDev(req, reply);
615
+ if (!t) return;
616
+ const { s, ws } = t;
617
+ if (!isId(req.params.id)) return reply.code(404).send('Not found');
618
+ const r = await owner.query('delete from meta.team_view where id = $1 and workspace_id = $2 and username = $3', [req.params.id, ws, s.username]);
619
+ if (!r.rowCount) return reply.code(404).send('Not found');
620
+ flash(s, 'View deleted.');
621
+ return back(reply, s, TEAM);
622
+ });
623
+
624
+ app.post(`${TEAM}/mentions/seen`, async (req: Req, reply) => {
625
+ const t = await teamDev(req, reply);
626
+ if (!t) return;
627
+ const { s, ws } = t;
628
+ await owner.query(`update meta.team_mention m set seen_at = now() from meta.team_issue i
629
+ where i.id = m.issue_id and i.workspace_id = $1 and m.username = $2 and m.seen_at is null`, [ws, s.username]);
630
+ flash(s, 'Mentions marked as seen.');
631
+ return back(reply, s, TEAM);
632
+ });
633
+
634
+ // ------------------------------------------------------------------ labels and label groups
635
+ app.get(`${TEAM}/labels`, async (req: Req, reply) => {
636
+ const t = await teamDev(req, reply);
637
+ if (!t) return;
638
+ const { s, ws } = t;
639
+ const [lk, admin, used] = await Promise.all([lookups(ws), workspaceAdmin(s.username, ws),
640
+ owner.query('select il.label_id, count(*)::int as n from meta.team_issue_label il join meta.team_issue i on i.id = il.issue_id where i.workspace_id = $1 and i.status = $2 group by 1', [ws, 'open'])]);
641
+ const n = new Map(used.rows.map((r) => [Number(r.label_id), r.n]));
642
+ const colorSel = (id: string, cur: string) => html`<select id="${id}" name="color">${LABEL_COLORS.map((c) => html`<option value="${c}"${c === cur ? raw(' selected') : ''}>${c}</option>`)}</select>`;
643
+ const groupSel = (id: string, cur: number | null) => html`<select id="${id}" name="group"><option value="">No group</option>${lk.groups.map((g) => html`<option value="${g.id}"${g.id === cur ? raw(' selected') : ''}>${g.name}</option>`)}</select>`;
644
+ const labelForm = (l: Partial<Label> & { id?: number }, p: string) => html`<form method="post" action="${TEAM}/labels${l.id ? `/${l.id}` : ''}">${csrf(s)}
645
+ <div class="form-grid">
646
+ <div class="field"><label class="label" for="${p}_name">Name</label><input id="${p}_name" name="name" maxlength="40" required value="${l.name ?? ''}"></div>
647
+ <div class="field"><label class="label" for="${p}_color">Colour</label>${colorSel(`${p}_color`, l.color ?? 'gray')}</div>
648
+ <div class="field"><label class="label" for="${p}_group">Group</label>${groupSel(`${p}_group`, l.group_id ?? null)}</div>
649
+ <div class="field"><label class="label" for="${p}_desc">Description</label><input id="${p}_desc" name="description" maxlength="500" value="${l.description ?? ''}"></div>
650
+ </div><div class="buttons"><button class="btn btn-sm btn-hot">${l.id ? 'Save' : 'Add label'}</button></div></form>`;
651
+ const byGroup = [...lk.groups.map((g) => ({ g, ls: lk.labels.filter((l) => String(l.group_id) === String(g.id)) })), { g: null, ls: lk.labels.filter((l) => l.group_id === null) }];
652
+ const main = html`${teamHeader('labels')}
653
+ ${byGroup.filter((x) => x.g || x.ls.length).map(({ g, ls }) => region(g ? g.name : 'Labels without a group', html`
654
+ ${g?.description ? html`<p class="muted small u-mt0">${g.description}</p>` : ''}
655
+ ${ls.length ? html`<ul class="team-label-list">${ls.map((l) => html`<li>${chip(l)} <span class="small muted">${l.description ?? ''}</span>
656
+ <a class="small" href="${TEAM}?label=${l.id}">${n.get(Number(l.id)) ?? 0} open</a>
657
+ <details class="team-inline-edit"><summary class="link-button">Edit</summary>${labelForm(l, `l${l.id}`)}
658
+ ${admin ? html`<form method="post" action="${TEAM}/labels/${l.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the label ${l.name}? Issues lose it.">${icon('trash')} Delete label</button></form>` : ''}</details></li>`)}</ul>`
659
+ : html`<p class="muted small">No labels in this group.</p>`}
660
+ ${g && admin ? html`<form method="post" action="${TEAM}/groups/${g.id}/delete" class="u-mt1">${csrf(s)}<button class="btn btn-sm" data-confirm="Delete the group ${g.name}? Its labels stay, without a group.">Delete group</button></form>` : ''}`))}
661
+ <div class="columns">
662
+ ${region('Add a label', labelForm({}, 'nl'))}
663
+ ${region('Add a label group', html`<form method="post" action="${TEAM}/groups">${csrf(s)}
664
+ <div class="field"><label class="label" for="ng_name">Name</label><input id="ng_name" name="name" maxlength="40" required placeholder="e.g. Area"></div>
665
+ <div class="field"><label class="label" for="ng_desc">Description</label><input id="ng_desc" name="description" maxlength="500"></div>
666
+ <div class="buttons"><button class="btn btn-sm btn-hot">Add group</button></div></form>
667
+ <p class="small muted">The board groups issues by the labels of one group. Only workspace administrators delete labels and groups.</p>`)}
668
+ </div>`;
669
+ return send(reply, s, page(s, 'Labels', [['Labels']], main));
670
+ });
671
+
672
+ const saveLabel = async (req: Req, reply: FastifyReply, id: string | null) => {
673
+ const t = await teamDev(req, reply);
674
+ if (!t) return;
675
+ const { s, ws } = t;
676
+ const b = req.body ?? {};
677
+ const name = text(b.name, 100);
678
+ const color = b.color;
679
+ const group = String(b.group ?? '');
680
+ const desc = text(b.description, 500) || null;
681
+ if (!isLabelColor(color)) return reply.code(400).send('Unknown colour');
682
+ if (group && !(await owner.one('select 1 as ok from meta.team_label_group where id = $1 and workspace_id = $2', [isId(group) ? group : 0, ws]))) return reply.code(400).send('Unknown group');
683
+ if (!NAME40.test(name)) {
684
+ flash(s, 'A label has a name of 1 to 40 characters.', 'error');
685
+ return back(reply, s, `${TEAM}/labels`);
686
+ }
687
+ try {
688
+ if (id) {
689
+ if (!isId(id)) return reply.code(404).send('Not found');
690
+ const r = await owner.query('update meta.team_label set name = $3, color = $4, group_id = $5, description = $6 where id = $1 and workspace_id = $2', [id, ws, name, color, group || null, desc]);
691
+ if (!r.rowCount) return reply.code(404).send('Not found');
692
+ } else await owner.query('insert into meta.team_label (workspace_id, name, color, group_id, description) values ($1, $2, $3, $4, $5)', [ws, name, color, group || null, desc]);
693
+ flash(s, `Label ${name} saved.`);
694
+ } catch (e) {
695
+ flash(s, /team_label_name/.test((e as Error).message) ? `A label ${name} exists already.` : (e as Error).message, 'error');
696
+ }
697
+ return back(reply, s, `${TEAM}/labels`);
698
+ };
699
+ app.post(`${TEAM}/labels`, (req: Req, reply) => saveLabel(req, reply, null));
700
+ app.post(`${TEAM}/labels/:id`, (req: Req, reply) => saveLabel(req, reply, req.params.id));
701
+
702
+ app.post(`${TEAM}/groups`, async (req: Req, reply) => {
703
+ const t = await teamDev(req, reply);
704
+ if (!t) return;
705
+ const { s, ws } = t;
706
+ const name = text(req.body?.name, 100);
707
+ if (!NAME40.test(name)) {
708
+ flash(s, 'A group has a name of 1 to 40 characters.', 'error');
709
+ return back(reply, s, `${TEAM}/labels`);
710
+ }
711
+ const r = await owner.query(`insert into meta.team_label_group (workspace_id, name, description, seq)
712
+ values ($1, $2, $3, (select coalesce(max(seq), 0) + 10 from meta.team_label_group where workspace_id = $1)) on conflict (workspace_id, lower(name)) do nothing`,
713
+ [ws, name, text(req.body?.description, 500) || null]);
714
+ flash(s, r.rowCount ? `Group ${name} added.` : `A group ${name} exists already.`, r.rowCount ? 'ok' : 'error');
715
+ return back(reply, s, `${TEAM}/labels`);
716
+ });
717
+
718
+ const adminDelete = (path: string, table: string, what: string, to: string) =>
719
+ app.post(`${TEAM}/${path}/:id/delete`, async (req: Req, reply) => {
720
+ const t = await teamDev(req, reply);
721
+ if (!t) return;
722
+ const { s, ws } = t;
723
+ if (!isId(req.params.id)) return reply.code(404).send('Not found');
724
+ if (!(await workspaceAdmin(s.username, ws))) return reply.code(403).send(`Only the workspace's administrators delete ${what}s.`);
725
+ const r = await owner.query(`delete from meta.${table} where id = $1 and workspace_id = $2`, [req.params.id, ws]);
726
+ if (!r.rowCount) return reply.code(404).send('Not found');
727
+ flash(s, `The ${what} is deleted.`);
728
+ return back(reply, s, to);
729
+ });
730
+ adminDelete('labels', 'team_label', 'label', `${TEAM}/labels`);
731
+ adminDelete('groups', 'team_label_group', 'label group', `${TEAM}/labels`);
732
+ adminDelete('milestones', 'team_milestone', 'milestone', `${TEAM}/milestones`);
733
+
734
+ // ------------------------------------------------------------------ milestones
735
+ app.get(`${TEAM}/milestones`, async (req: Req, reply) => {
736
+ const t = await teamDev(req, reply);
737
+ if (!t) return;
738
+ const { s, ws } = t;
739
+ const [lk, admin] = await Promise.all([lookups(ws), workspaceAdmin(s.username, ws)]);
740
+ const form = (m: Partial<Milestone>, p: string) => html`<form method="post" action="${TEAM}/milestones${m.id ? `/${m.id}` : ''}">${csrf(s)}
741
+ <div class="form-grid">
742
+ <div class="field"><label class="label" for="${p}_name">Name</label><input id="${p}_name" name="name" maxlength="40" required value="${m.name ?? ''}"></div>
743
+ <div class="field"><label class="label" for="${p}_due">Due</label><input id="${p}_due" name="due_date" type="date" value="${m.due_date ?? ''}"></div>
744
+ <div class="field"><label class="label" for="${p}_status">Status</label><select id="${p}_status" name="status">
745
+ <option value="open"${m.status !== 'closed' ? raw(' selected') : ''}>Open</option><option value="closed"${m.status === 'closed' ? raw(' selected') : ''}>Closed</option></select></div>
746
+ <div class="field"><label class="label" for="${p}_desc">Description</label><input id="${p}_desc" name="description" maxlength="500" value="${m.description ?? ''}"></div>
747
+ </div><div class="buttons"><button class="btn btn-sm btn-hot">${m.id ? 'Save' : 'Add milestone'}</button></div></form>`;
748
+ const main = html`${teamHeader('milestones')}
749
+ ${region('Milestones', lk.milestones.length ? html`<ul class="team-milestones">${lk.milestones.map((m) => {
750
+ const total = m.open + m.closed;
751
+ return html`<li class="team-milestone${m.status === 'closed' ? ' is-closed' : ''}">
752
+ <div class="team-milestone-head"><b>${icon('flag')}${m.name}</b>${m.status === 'closed' ? html` <span class="tag">closed</span>` : ''}
753
+ <span class="small muted">${m.due_date ? `due ${m.due_date}` : 'no due date'}</span></div>
754
+ ${m.description ? html`<p class="small muted">${m.description}</p>` : ''}
755
+ <progress class="team-progress" max="${total || 1}" value="${m.closed}" aria-label="${m.closed} of ${total} issues closed"></progress>
756
+ <p class="small"><a href="${TEAM}?milestone=${m.id}">${m.open} open</a> · <a href="${TEAM}?milestone=${m.id}&amp;status=closed">${m.closed} closed</a></p>
757
+ <details class="team-inline-edit"><summary class="link-button">Edit</summary>${form(m, `m${m.id}`)}
758
+ ${admin ? html`<form method="post" action="${TEAM}/milestones/${m.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the milestone ${m.name}? Its issues keep no milestone.">${icon('trash')} Delete milestone</button></form>` : ''}</details>
759
+ </li>`;
760
+ })}</ul>` : html`<p class="muted">No milestones yet.</p>`)}
761
+ ${region('Add a milestone', form({}, 'nm'))}`;
762
+ return send(reply, s, page(s, 'Milestones', [['Milestones']], main));
763
+ });
764
+
765
+ const saveMilestone = async (req: Req, reply: FastifyReply, id: string | null) => {
766
+ const t = await teamDev(req, reply);
767
+ if (!t) return;
768
+ const { s, ws } = t;
769
+ const b = req.body ?? {};
770
+ const name = text(b.name, 100);
771
+ const due = String(b.due_date ?? '');
772
+ if (!NAME40.test(name) || (due && (!/^\d{4}-\d{2}-\d{2}$/.test(due) || Number.isNaN(Date.parse(due))))) {
773
+ flash(s, 'A milestone has a name of 1 to 40 characters and an optional date.', 'error');
774
+ return back(reply, s, `${TEAM}/milestones`);
775
+ }
776
+ const vals = [name, text(b.description, 500) || null, due || null, b.status === 'closed' ? 'closed' : 'open'];
777
+ try {
778
+ if (id) {
779
+ if (!isId(id)) return reply.code(404).send('Not found');
780
+ const r = await owner.query('update meta.team_milestone set name = $3, description = $4, due_date = $5, status = $6 where id = $1 and workspace_id = $2', [id, ws, ...vals]);
781
+ if (!r.rowCount) return reply.code(404).send('Not found');
782
+ } else await owner.query('insert into meta.team_milestone (workspace_id, name, description, due_date, status) values ($1, $2, $3, $4, $5)', [ws, ...vals]);
783
+ flash(s, `Milestone ${name} saved.`);
784
+ } catch (e) {
785
+ flash(s, /team_milestone_name/.test((e as Error).message) ? `A milestone ${name} exists already.` : (e as Error).message, 'error');
786
+ }
787
+ return back(reply, s, `${TEAM}/milestones`);
788
+ };
789
+ app.post(`${TEAM}/milestones`, (req: Req, reply) => saveMilestone(req, reply, null));
790
+ app.post(`${TEAM}/milestones/:id`, (req: Req, reply) => saveMilestone(req, reply, req.params.id));
791
+
792
+ // ------------------------------------------------------------------ templates (issue templates and canned responses)
793
+ app.get(`${TEAM}/templates`, async (req: Req, reply) => {
794
+ const t = await teamDev(req, reply);
795
+ if (!t) return;
796
+ const { s, ws } = t;
797
+ const rows = (await owner.query('select id, kind, name, title, body, labels from meta.team_template where workspace_id = $1 order by kind, seq, lower(name)', [ws])).rows;
798
+ const form = (x: { id?: number; kind?: string; name?: string; title?: string | null; body?: string; labels?: string[] }, p: string) => html`<form method="post" action="${TEAM}/templates${x.id ? `/${x.id}` : ''}">${csrf(s)}
799
+ <div class="form-grid">
800
+ <div class="field"><label class="label" for="${p}_name">Name</label><input id="${p}_name" name="name" maxlength="60" required value="${x.name ?? ''}"></div>
801
+ <div class="field"><label class="label" for="${p}_kind">Kind</label><select id="${p}_kind" name="kind">
802
+ <option value="issue"${x.kind !== 'response' ? raw(' selected') : ''}>Issue template</option><option value="response"${x.kind === 'response' ? raw(' selected') : ''}>Canned response</option></select></div>
803
+ <div class="field"><label class="label" for="${p}_title">Issue title</label><input id="${p}_title" name="title" maxlength="200" value="${x.title ?? ''}"><small class="help">Issue templates only.</small></div>
804
+ <div class="field"><label class="label" for="${p}_labels">Labels</label><input id="${p}_labels" name="labels" maxlength="500" value="${(x.labels ?? []).join(', ')}"><small class="help">Issue templates: label names, separated by commas.</small></div>
805
+ </div>
806
+ <div class="field"><label class="label" for="${p}_body">Text</label><textarea id="${p}_body" name="body" rows="6" maxlength="20000">${x.body ?? ''}</textarea><small class="help">Markdown.</small></div>
807
+ <div class="buttons"><button class="btn btn-sm btn-hot">${x.id ? 'Save' : 'Add template'}</button></div></form>`;
808
+ const list = (kind: string) => {
809
+ const xs = rows.filter((r) => r.kind === kind);
810
+ return xs.length ? html`<ul class="team-template-list">${xs.map((x) => html`<li><b>${x.name}</b>
811
+ ${kind === 'issue' ? html` <a class="small" href="${TEAM}/issues/new?template=${x.id}">Use</a>` : ''}
812
+ <details class="team-inline-edit"><summary class="link-button">Edit</summary>${form(x, `t${x.id}`)}
813
+ <form method="post" action="${TEAM}/templates/${x.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the template ${x.name}?">${icon('trash')} Delete template</button></form></details></li>`)}</ul>`
814
+ : html`<p class="muted small">None yet.</p>`;
815
+ };
816
+ const main = html`${teamHeader('templates')}
817
+ <div class="columns">${region('Issue templates', list('issue'))}${region('Canned responses', list('response'))}</div>
818
+ ${region('Add a template', form({}, 'nt'))}`;
819
+ return send(reply, s, page(s, 'Templates', [['Templates']], main));
820
+ });
821
+
822
+ const saveTemplate = async (req: Req, reply: FastifyReply, id: string | null) => {
823
+ const t = await teamDev(req, reply);
824
+ if (!t) return;
825
+ const { s, ws } = t;
826
+ const b = req.body ?? {};
827
+ const name = text(b.name, 100);
828
+ const kind = b.kind === 'response' ? 'response' : 'issue';
829
+ const body = String(b.body ?? '').replace(/\r\n?/g, '\n');
830
+ const labels = String(b.labels ?? '').split(',').map((x) => x.trim()).filter((x) => NAME40.test(x)).slice(0, 20);
831
+ if (!/^\S(.{0,58}\S)?$/.test(name) || body.length > 20000) {
832
+ flash(s, 'A template has a name of 1 to 60 characters and up to 20000 characters of text.', 'error');
833
+ return back(reply, s, `${TEAM}/templates`);
834
+ }
835
+ const vals = [kind, name, kind === 'issue' ? text(b.title, 200) || null : null, body, kind === 'issue' ? labels : []];
836
+ try {
837
+ if (id) {
838
+ if (!isId(id)) return reply.code(404).send('Not found');
839
+ const r = await owner.query('update meta.team_template set kind = $3, name = $4, title = $5, body = $6, labels = $7 where id = $1 and workspace_id = $2', [id, ws, ...vals]);
840
+ if (!r.rowCount) return reply.code(404).send('Not found');
841
+ } else await owner.query('insert into meta.team_template (workspace_id, kind, name, title, body, labels) values ($1, $2, $3, $4, $5, $6)', [ws, ...vals]);
842
+ flash(s, `Template ${name} saved.`);
843
+ } catch (e) {
844
+ flash(s, /team_template_name/.test((e as Error).message) ? `A template ${name} exists already.` : (e as Error).message, 'error');
845
+ }
846
+ return back(reply, s, `${TEAM}/templates`);
847
+ };
848
+ app.post(`${TEAM}/templates`, (req: Req, reply) => saveTemplate(req, reply, null));
849
+ app.post(`${TEAM}/templates/:id`, (req: Req, reply) => saveTemplate(req, reply, req.params.id));
850
+ app.post(`${TEAM}/templates/:id/delete`, async (req: Req, reply) => {
851
+ const t = await teamDev(req, reply);
852
+ if (!t) return;
853
+ const { s, ws } = t;
854
+ if (!isId(req.params.id)) return reply.code(404).send('Not found');
855
+ const r = await owner.query('delete from meta.team_template where id = $1 and workspace_id = $2', [req.params.id, ws]);
856
+ if (!r.rowCount) return reply.code(404).send('Not found');
857
+ flash(s, 'Template deleted.');
858
+ return back(reply, s, `${TEAM}/templates`);
859
+ });
860
+
861
+ // ------------------------------------------------------------------ the set-up wizard
862
+ app.get(`${TEAM}/setup`, async (req: Req, reply) => {
863
+ const t = await teamDev(req, reply);
864
+ if (!t) return;
865
+ const { s } = t;
866
+ const en = TEAM_SAMPLES.en;
867
+ const main = html`${teamHeader('setup')}
868
+ ${region('Set up Team Development', html`
869
+ <p class="u-mt0">Installs sample data for ${s.workspace?.name ?? 'this workspace'}; what exists already (by name) is kept, so you can run it again.</p>
870
+ <form method="post" action="${TEAM}/setup">${csrf(s)}
871
+ <div class="field"><label class="label" for="f_lang">Language</label><select id="f_lang" name="lang">${TEAM_LANGUAGES.map((l) => html`<option value="${l}">${TEAM_SAMPLES[l].language}</option>`)}</select></div>
872
+ <fieldset class="team-fieldset"><legend>Install</legend>
873
+ <label class="check"><input type="checkbox" name="labels" value="true" checked> Label groups and labels: ${en.groups.map((g) => `${g.name} (${g.labels.map((l) => l.name).join(', ')})`).join('; ')}</label>
874
+ <label class="check"><input type="checkbox" name="templates" value="true" checked> Templates: ${en.issueTemplates.map((x) => x.name).join(', ')}; canned responses (${en.responses.length})</label>
875
+ <label class="check"><input type="checkbox" name="milestones" value="true" checked> Milestones: ${en.milestones.map((m) => m.name).join(', ')}</label>
876
+ </fieldset>
877
+ <div class="buttons"><button class="btn btn-hot">Set up</button></div></form>`)}`;
878
+ return send(reply, s, page(s, 'Set up', [['Set up']], main));
879
+ });
880
+
881
+ app.post(`${TEAM}/setup`, async (req: Req, reply) => {
882
+ const t = await teamDev(req, reply);
883
+ if (!t) return;
884
+ const { s, ws } = t;
885
+ const lang = String(req.body?.lang ?? 'en');
886
+ if (!TEAM_LANGUAGES.includes(lang)) return reply.code(400).send('Unknown language');
887
+ const b = req.body ?? {};
888
+ const n = await owner.tx((c) => teamSetup(c, ws, lang, { labels: b.labels === 'true', templates: b.templates === 'true', milestones: b.milestones === 'true' }));
889
+ await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `team: set up (${lang})` });
890
+ flash(s, `Set up: ${n.groups} label group(s), ${n.labels} label(s), ${n.templates} template(s) and ${n.milestones} milestone(s) added.`);
891
+ return back(reply, s, TEAM);
892
+ });
893
+
894
+ // ------------------------------------------------------------------ issues from feedback and developer comments
895
+ app.post(`${TEAM}/from-feedback`, async (req: Req, reply) => {
896
+ const s = await developer(req, reply);
897
+ if (!s) return;
898
+ const fid = String(req.body?.feedback ?? '');
899
+ const f = isId(fid) ? await owner.one('select f.*, a.name as app_name from meta.feedback f join meta.app a on a.id = f.app_id where f.id = $1', [fid]) : undefined;
900
+ // the feedback's application must be one the developer may open (that makes its workspace the current one)
901
+ if (!f || !(await appAllowed(s, f.app_id))) return reply.code(404).send('Not found');
902
+ const ws = (await appWorkspace(f.app_id))!;
903
+ const existing = await owner.one('select id from meta.team_issue where feedback_id = $1 and workspace_id = $2', [f.id, ws]);
904
+ if (existing) return back(reply, s, `${TEAM}/issues/${existing.id}`);
905
+ const kindLabel = f.kind === 'problem' ? 'bug' : f.kind === 'idea' ? 'feature' : null;
906
+ const title = `${f.kind === 'problem' ? 'Problem' : f.kind === 'idea' ? 'Idea' : 'Feedback'}: ${firstLine(f.body)}`;
907
+ const body = `Feedback of **${mdEscape(f.username)}** (${f.kind}${f.rating ? `, ${f.rating} of 5` : ''}) on ${f.page_no ? `page ${f.page_no} of ` : ''}${mdEscape(f.app_name)}:\n\n${quote(f.body)}`;
908
+ const id = await owner.tx(async (c) => {
909
+ const id = Number((await c.query(`insert into meta.team_issue (workspace_id, title, body, app_id, page_no, feedback_id, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
910
+ [ws, title, body, f.app_id, f.page_no && (await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [f.app_id, f.page_no])).rowCount ? f.page_no : null, f.id, s.username])).rows[0].id);
911
+ if (kindLabel) await c.query(`insert into meta.team_issue_label (issue_id, label_id) select $1, id from meta.team_label where workspace_id = $2 and lower(name) = $3`, [id, ws, kindLabel]);
912
+ await logIssue(c, id, s.username, 'created', `from feedback of ${f.username}`);
913
+ if (f.status === 'open') await c.query(`update meta.feedback set status = 'in_progress' where id = $1`, [f.id]);
914
+ return id;
915
+ });
916
+ flash(s, `Issue #${id} created from the feedback.`);
917
+ return back(reply, s, `${TEAM}/issues/${id}`);
918
+ });
919
+
920
+ app.post(`${TEAM}/from-comment`, async (req: Req, reply) => {
921
+ const s = await developer(req, reply);
922
+ if (!s) return;
923
+ const cid = String(req.body?.comment ?? '');
924
+ const c0 = /^\d{1,9}$/.test(cid) ? await owner.one('select c.*, a.name as app_name from meta.dev_comment c join meta.app a on a.id = c.app_id where c.id = $1', [cid]) : undefined;
925
+ if (!c0 || !(await appAllowed(s, c0.app_id))) return reply.code(404).send('Not found');
926
+ const ws = (await appWorkspace(c0.app_id))!;
927
+ const existing = await owner.one('select id from meta.team_issue where dev_comment_id = $1 and workspace_id = $2', [c0.id, ws]);
928
+ if (existing) return back(reply, s, `${TEAM}/issues/${existing.id}`);
929
+ const body = `Developer comment of **${mdEscape(c0.author)}** on ${c0.page_no ? `page ${c0.page_no} of ` : ''}${mdEscape(c0.app_name)}:\n\n${quote(c0.body)}`;
930
+ const id = await owner.tx(async (c) => {
931
+ const id = Number((await c.query(`insert into meta.team_issue (workspace_id, title, body, app_id, page_no, dev_comment_id, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
932
+ [ws, firstLine(c0.body), body, c0.app_id, c0.page_no || null, c0.id, s.username])).rows[0].id);
933
+ await logIssue(c, id, s.username, 'created', `from a comment of ${c0.author}`);
934
+ await recordMentions(c, ws, id, null, c0.body, s.username);
935
+ return id;
936
+ });
937
+ flash(s, `Issue #${id} created from the comment.`);
938
+ return back(reply, s, `${TEAM}/issues/${id}`);
939
+ });
940
+ }
941
+
942
+ const firstLine = (t: string) => {
943
+ const l = String(t).split('\n').map((x) => x.trim()).find(Boolean) ?? 'Untitled';
944
+ return l.length > 80 ? `${l.slice(0, 79)}…` : l;
945
+ };
946
+ const mdEscape = (t: string) => String(t).replace(/[\\`*_[\]<>#]/g, (c) => `\\${c}`);
947
+ const quote = (t: string) => String(t).split('\n').map((l) => `> ${l}`).join('\n');
948
+
949
+ // ------------------------------------------------------------------ pieces for other pages
950
+
951
+ /** (Page Designer) the open issues of a page: a line with links, or nothing. */
952
+ export async function pageIssuesNote(appId: number, pageNo: number): Promise<Raw | ''> {
953
+ const rows = await pageIssues(appId, pageNo);
954
+ if (!rows.length) return '';
955
+ const all = `${TEAM}?app=${appId}&page=${pageNo}`;
956
+ return html`<div class="team-page-issues small" role="note">${icon('kanban')}<span>${rows.length} open issue${rows.length === 1 ? '' : 's'}:
957
+ ${rows.slice(0, 3).map((r, i) => html`${i ? ', ' : ''}<a href="${TEAM}/issues/${r.id}">${r.title}</a>`)}${rows.length > 3 ? html`, <a href="${all}">all ${rows.length}</a>` : ''}
958
+ · <a href="${TEAM}/issues/new?app=${appId}&amp;page=${pageNo}">New issue</a></span></div>`;
959
+ }
960
+
961
+ /** (App Home) open issues per page, and the Tasks entry. */
962
+ export async function appIssues(appId: number) {
963
+ const c = await appIssueCounts(appId);
964
+ return {
965
+ tag: (pageNo: number) => {
966
+ const n = c.perPage.get(pageNo);
967
+ return n ? html` <a class="tag tag-warning" href="${TEAM}?app=${appId}&amp;page=${pageNo}" title="Open issues of this page">${n} issue${n === 1 ? '' : 's'}</a>` : '';
968
+ },
969
+ task: html`<li><a href="${TEAM}?app=${appId}">${icon('kanban')} Open issues (${c.total})</a></li>`,
970
+ };
971
+ }
972
+
973
+ /** (Workspace home) the side column's Team Development block. */
974
+ export async function teamHomeBlock(s: Session) {
975
+ const ws = currentWorkspace(s);
976
+ if (ws < 0) return '';
977
+ const c = await teamCounts(ws, s.username);
978
+ return html`<section class="wh-team" aria-labelledby="wh-team-h"><h2 id="wh-team-h">Team Development</h2>
979
+ <ul class="ab-links ab-tasks">
980
+ <li><a href="${TEAM}">${icon('kanban')} Open issues <b class="wh-team-n">${c.open}</b></a></li>
981
+ <li><a href="${TEAM}?assignee=me">${icon('user')} Assigned to you <b class="wh-team-n">${c.mine}</b></a></li>
982
+ ${c.mentions ? html`<li><a href="${TEAM}#mentions">${icon('at')} Mentions of you <b class="wh-team-n">${c.mentions}</b></a></li>` : ''}
983
+ ${c.set_up ? '' : html`<li><a href="${TEAM}/setup">${icon('settings')} Set up Team Development</a></li>`}
984
+ </ul></section>`;
985
+ }
986
+
987
+ /** (Feedback, developer comments) a button that turns the entry into an issue, or the link to its issue. */
988
+ export function toIssueButton(s: Session, kind: 'feedback' | 'comment', id: number, issue: number | undefined) {
989
+ if (issue) return html`<a class="small team-issue-link" href="${TEAM}/issues/${issue}">${icon('kanban')} Issue #${issue}</a>`;
990
+ return html`<form method="post" action="${TEAM}/from-${kind}" class="u-inline">${csrf(s)}<input type="hidden" name="${kind}" value="${id}"><button class="link-button">Make an issue</button></form>`;
991
+ }