soja-cli 1.5.1 → 1.6.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 (33) hide show
  1. package/dist/application/finance.js +1 -0
  2. package/dist/application/intake.js +1 -0
  3. package/dist/application/services/task-service.js +26 -2
  4. package/dist/application/services/workspace-service.js +5 -2
  5. package/dist/application/timeline.js +2 -0
  6. package/dist/cli/commands/workspace.js +12 -1
  7. package/dist/cli/output.js +2 -1
  8. package/dist/data/local/tasks.js +5 -1
  9. package/dist/data/local/workspaces.js +6 -3
  10. package/dist/data/sync/engine.js +1 -1
  11. package/dist/data/sync/finance.js +15 -0
  12. package/dist/data/sync/index.js +4 -0
  13. package/dist/data/sync/intake.js +27 -0
  14. package/dist/data/sync/services.js +7 -2
  15. package/dist/data/sync/store.js +3 -3
  16. package/dist/database/migrations/0007_finances.sql +5 -0
  17. package/dist/database/migrations/0008_finances_designates.sql +44 -0
  18. package/dist/database/migrations/0009_external_approval.sql +1 -0
  19. package/dist/database/migrations/meta/0008_snapshot.json +1221 -0
  20. package/dist/database/migrations/meta/0009_snapshot.json +1228 -0
  21. package/dist/database/migrations/meta/_journal.json +21 -0
  22. package/dist/database/schema.js +7 -0
  23. package/dist/ui/Shell.js +7 -0
  24. package/dist/ui/chrome/Sidebar.js +1 -1
  25. package/dist/ui/components/TaskTable.js +1 -1
  26. package/dist/ui/hooks/use-commands.js +1 -0
  27. package/dist/ui/hooks/use-task-actions.js +36 -0
  28. package/dist/ui/screens/FinanceScreen.js +88 -0
  29. package/dist/ui/screens/HelpScreen.js +9 -0
  30. package/dist/ui/screens/ProjectsScreen.js +185 -1
  31. package/dist/ui/screens/TaskScreen.js +9 -1
  32. package/dist/ui/screens/WorkspacesScreen.js +29 -1
  33. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -30,6 +30,9 @@ const changesSchema = z.object({
30
30
  status: z.enum(TASK_STATUSES).optional(),
31
31
  assigneeId: z.string().nullable().optional(),
32
32
  requester: optionalText(80),
33
+ remunerated: z.boolean().optional(),
34
+ priceMinor: z.number().int().nonnegative().nullable().optional(),
35
+ currencyCode: z.string().regex(/^[A-Z]{3}$/).nullable().optional(),
33
36
  branch: optionalText(250),
34
37
  /** Bookkeeping for merges; changes are not shown in the timeline. */
35
38
  baseBranch: optionalText(250),
@@ -125,6 +128,10 @@ export class TaskService {
125
128
  assigneeId,
126
129
  creatorId: session.user.id,
127
130
  requester: data.requester ?? null,
131
+ remunerated: false,
132
+ priceMinor: null,
133
+ externalApprovalStatus: null,
134
+ currencyCode: null,
128
135
  branch: null,
129
136
  baseBranch: null,
130
137
  branchStart: null,
@@ -164,6 +171,23 @@ export class TaskService {
164
171
  patch.requester = data.requester;
165
172
  events.push({ type: 'task_updated', metadata: { field: 'requester', from: task.requester, to: data.requester } });
166
173
  }
174
+ const nextRemunerated = data.remunerated ?? task.remunerated;
175
+ const nextPrice = data.remunerated === false && data.priceMinor === undefined ? null : data.priceMinor === undefined ? task.priceMinor : data.priceMinor;
176
+ const nextCurrency = data.remunerated === false && data.currencyCode === undefined ? null : data.currencyCode === undefined ? task.currencyCode : data.currencyCode;
177
+ if (nextRemunerated && (nextPrice === null || nextCurrency === null))
178
+ throw new ValidationError('A paid task needs a price and currency.');
179
+ if (!nextRemunerated && (nextPrice !== null || nextCurrency !== null))
180
+ throw new ValidationError('Remove the price and currency before marking a task unpaid.');
181
+ if (nextRemunerated !== task.remunerated || nextPrice !== task.priceMinor || nextCurrency !== task.currencyCode) {
182
+ patch.remunerated = nextRemunerated;
183
+ patch.priceMinor = nextPrice;
184
+ patch.currencyCode = nextCurrency;
185
+ events.push({ type: 'task_updated', metadata: {
186
+ field: 'price',
187
+ from: task.priceMinor === null ? null : `${task.priceMinor} ${task.currencyCode ?? ''}`.trim(),
188
+ to: nextPrice === null ? null : `${nextPrice} ${nextCurrency ?? ''}`.trim(),
189
+ } });
190
+ }
167
191
  if (changed(data.branch, task.branch)) {
168
192
  patch.branch = data.branch;
169
193
  events.push({ type: 'task_updated', metadata: { field: 'branch', from: task.branch, to: data.branch } });
@@ -289,7 +313,7 @@ export class TaskService {
289
313
  async resolve(session, target) {
290
314
  if (typeof target === 'object') {
291
315
  const task = await this.repos.tasks.findById(target.id);
292
- if (task && task.workspaceId === session.workspace.id)
316
+ if (task && task.workspaceId === session.workspace.id && task.externalApprovalStatus !== 'pending' && task.externalApprovalStatus !== 'rejected')
293
317
  return task;
294
318
  throw new NotFoundError('That task no longer exists.');
295
319
  }
@@ -298,7 +322,7 @@ export class TaskService {
298
322
  throw new ValidationError(`“${String(target)}” is not a task ID.`, { hint: 'Use something like SOJA-12.' });
299
323
  }
300
324
  const task = await this.repos.tasks.findByNumber(session.workspace.id, number);
301
- if (!task)
325
+ if (!task || task.externalApprovalStatus === 'pending' || task.externalApprovalStatus === 'rejected')
302
326
  throw new NotFoundError(`${formatTaskRef(number)} does not exist in ${session.workspace.name}.`);
303
327
  return task;
304
328
  }
@@ -44,7 +44,7 @@ export class WorkspaceService {
44
44
  }
45
45
  async members(session) {
46
46
  const members = await this.repos.workspaces.listMembers(session.workspace.id);
47
- return members.map(({ id, username, displayName, role }) => ({ id, username, displayName, role }));
47
+ return members.map(({ id, username, displayName, role, designated }) => ({ id, username, displayName, role, designated: role === 'owner' || Boolean(designated) }));
48
48
  }
49
49
  async findMember(session, username) {
50
50
  const normalized = username.trim().replace(/^@/, '').toLowerCase();
@@ -54,6 +54,9 @@ export class WorkspaceService {
54
54
  }
55
55
  return member;
56
56
  }
57
+ async setDesignated(session, userId, designated) {
58
+ await this.repos.workspaces.setDesignated(session.workspace.id, userId, designated);
59
+ }
57
60
  /**
58
61
  * Adds a developer to the workspace. In local mode developers are plain
59
62
  * records (no login), so an unknown username creates one.
@@ -67,7 +70,7 @@ export class WorkspaceService {
67
70
  throw new ConflictError(`@${username} is already in ${session.workspace.name}.`);
68
71
  }
69
72
  await this.repos.workspaces.addMember({ workspaceId: session.workspace.id, userId: user.id, role: 'member' });
70
- return { id: user.id, username: user.username, displayName: user.displayName, role: 'member' };
73
+ return { id: user.id, username: user.username, displayName: user.displayName, role: 'member', designated: false };
71
74
  });
72
75
  }
73
76
  }
@@ -74,6 +74,8 @@ export function describe(item, lookups) {
74
74
  return to ? `set requester to ${to}` : 'cleared the requester';
75
75
  case 'branch':
76
76
  return to ? `linked branch ${to}` : 'unlinked the branch';
77
+ case 'price':
78
+ return to ? `set ticket price to ${to}` : 'removed the ticket price';
77
79
  }
78
80
  }
79
81
  }
@@ -13,7 +13,9 @@ export async function workspaceCommand(args) {
13
13
  return create(rest);
14
14
  if (sub === 'add' || sub === 'add-member')
15
15
  return addMember(rest);
16
- throw new ValidationError(`Unknown workspace command “${sub}”.`, { hint: 'Try: list, create <name>, add <username>, use <workspace>.' });
16
+ if (sub === 'designate')
17
+ return designate(rest);
18
+ throw new ValidationError(`Unknown workspace command “${sub}”.`, { hint: 'Try: list, create <name>, add <username>, designate <username>.' });
17
19
  }
18
20
  async function list() {
19
21
  await withSession(async (services, session) => {
@@ -53,3 +55,12 @@ async function addMember(args) {
53
55
  success(`@${member.username} joined ${bold(session.workspace.name)}`);
54
56
  });
55
57
  }
58
+ async function designate(args) {
59
+ const { values, positionals } = parseCommand(args, { remove: { type: 'boolean', short: 'r' } });
60
+ const username = requireArg(positionals[0], 'workspace member', 'soja workspace designate angel');
61
+ await withSession(async (services, session) => {
62
+ const member = await services.workspaces.findMember(session, username);
63
+ await services.workspaces.setDesignated(session, member.id, !values.remove);
64
+ success(`${values.remove ? 'Removed designate access from' : 'Designated'} @${member.username} ${values.remove ? 'in ' + bold(session.workspace.name) : 'for ' + bold(session.workspace.name)}`);
65
+ });
66
+ }
@@ -51,6 +51,7 @@ export function taskLine(task, options = {}, columns = process.stdout.columns ||
51
51
  used += 11;
52
52
  }
53
53
  const room = Math.max(12, columns - used - 2);
54
- parts.push(task.title.length > room ? `${task.title.slice(0, room - 1)}${symbols.ellipsis}` : task.title);
54
+ const title = `${task.remunerated ? '$ ' : ''}${task.title}`;
55
+ parts.push(title.length > room ? `${title.slice(0, room - 1)}${symbols.ellipsis}` : title);
55
56
  return ` ${parts.join('')}`;
56
57
  }
@@ -44,6 +44,9 @@ export class LocalTaskRepository {
44
44
  }
45
45
  async list(query) {
46
46
  const conditions = [eq(tasks.workspaceId, query.workspaceId)];
47
+ const externallyVisible = or(isNull(tasks.externalApprovalStatus), eq(tasks.externalApprovalStatus, 'approved'));
48
+ if (externallyVisible)
49
+ conditions.push(externallyVisible);
47
50
  if (query.assigneeId)
48
51
  conditions.push(eq(tasks.assigneeId, query.assigneeId));
49
52
  if (query.projectId)
@@ -79,10 +82,11 @@ export class LocalTaskRepository {
79
82
  return task;
80
83
  }
81
84
  async countByProjectAndStatus(workspaceId) {
85
+ const externallyVisible = or(isNull(tasks.externalApprovalStatus), eq(tasks.externalApprovalStatus, 'approved'));
82
86
  return this.db
83
87
  .select({ projectId: tasks.projectId, status: tasks.status, count: count() })
84
88
  .from(tasks)
85
- .where(and(eq(tasks.workspaceId, workspaceId), isNull(tasks.archivedAt)))
89
+ .where(and(eq(tasks.workspaceId, workspaceId), isNull(tasks.archivedAt), ...(externallyVisible ? [externallyVisible] : [])))
86
90
  .groupBy(tasks.projectId, tasks.status);
87
91
  }
88
92
  async delete(id) {
@@ -25,7 +25,7 @@ export class LocalWorkspaceRepository {
25
25
  }
26
26
  async listForUser(userId) {
27
27
  const rows = await this.db
28
- .select({ workspace: workspaces, role: workspaceMembers.role })
28
+ .select({ workspace: workspaces, role: workspaceMembers.role, designated: workspaceMembers.designated })
29
29
  .from(workspaceMembers)
30
30
  .innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId))
31
31
  .where(eq(workspaceMembers.userId, userId))
@@ -45,11 +45,14 @@ export class LocalWorkspaceRepository {
45
45
  }
46
46
  async listMembers(workspaceId) {
47
47
  const rows = await this.db
48
- .select({ user: users, role: workspaceMembers.role })
48
+ .select({ user: users, role: workspaceMembers.role, designated: workspaceMembers.designated })
49
49
  .from(workspaceMembers)
50
50
  .innerJoin(users, eq(users.id, workspaceMembers.userId))
51
51
  .where(eq(workspaceMembers.workspaceId, workspaceId))
52
52
  .orderBy(asc(users.username));
53
- return rows.map((row) => ({ ...row.user, role: row.role }));
53
+ return rows.map((row) => ({ ...row.user, role: row.role, designated: row.role === 'owner' || row.designated }));
54
+ }
55
+ async setDesignated(workspaceId, userId, designated) {
56
+ await this.db.update(workspaceMembers).set({ designated }).where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, userId)));
54
57
  }
55
58
  }
@@ -3,7 +3,7 @@ import { formatTaskRef, isProvisional, TASK_REF_PREFIX } from '../../domain/task
3
3
  import { OfflineError } from '../remote/api-client.js';
4
4
  import { isChatOp } from './store.js';
5
5
  const BATCH = 100;
6
- const TASK_FIELDS = new Set(['title', 'description', 'projectId', 'type', 'priority', 'status', 'assigneeId', 'requester', 'branch', 'baseBranch', 'branchStart']);
6
+ const TASK_FIELDS = new Set(['title', 'description', 'projectId', 'type', 'priority', 'status', 'assigneeId', 'requester', 'remunerated', 'priceMinor', 'currencyCode', 'branch', 'baseBranch', 'branchStart']);
7
7
  /** Server task view → stored task row (presentation fields are dropped by the store). */
8
8
  const toTask = (view) => view;
9
9
  const emptyReport = () => ({
@@ -0,0 +1,15 @@
1
+ export class ReplicaFinanceService {
2
+ context;
3
+ constructor(context) {
4
+ this.context = context;
5
+ }
6
+ summary(session) {
7
+ return this.context.api.get(`/v1/workspaces/${session.workspace.id}/finance`);
8
+ }
9
+ async configureCurrency(session, currencyCode) {
10
+ await this.context.api.request('PUT', `/v1/workspaces/${session.workspace.id}/finance/currency`, { currencyCode });
11
+ }
12
+ async setMySalary(session, input) {
13
+ await this.context.api.request('PUT', `/v1/workspaces/${session.workspace.id}/finance/salary`, input);
14
+ }
15
+ }
@@ -7,6 +7,8 @@ import { TaskService } from '../../application/services/task-service.js';
7
7
  import { createLocalRepositories } from '../local/index.js';
8
8
  import { LiveConnection } from '../remote/live.js';
9
9
  import { ReplicaChatService } from './chat.js';
10
+ import { ReplicaFinanceService } from './finance.js';
11
+ import { ReplicaIntakeService } from './intake.js';
10
12
  import { LocalImporter } from './importer.js';
11
13
  import { SyncEngine } from './engine.js';
12
14
  import { ReplicaProjectService, ReplicaSessionService, ReplicaTaskService, ReplicaWorkspaceService } from './services.js';
@@ -83,6 +85,8 @@ export function createReplicaServices(api, handle, config, git, gitConsole, WebS
83
85
  projects,
84
86
  tasks,
85
87
  chat: new ReplicaChatService(context, tasks),
88
+ finance: new ReplicaFinanceService(context),
89
+ intake: new ReplicaIntakeService(context),
86
90
  ...(localDatabaseFile ? { importer: new LocalImporter(localDatabaseFile, config, api, store, engine) } : {}),
87
91
  git: new GitWorkflowService(tasks, projects, git),
88
92
  folders: new FolderService(config),
@@ -0,0 +1,27 @@
1
+ export class ReplicaIntakeService {
2
+ context;
3
+ constructor(context) {
4
+ this.context = context;
5
+ }
6
+ configure(session, projectId, input) {
7
+ return this.context.api.request('PUT', `${this.base(session)}/projects/${projectId}/intake`, input);
8
+ }
9
+ rotateSecret(session, projectId) {
10
+ return this.context.api.request('POST', `${this.base(session)}/projects/${projectId}/intake/secret`);
11
+ }
12
+ exportContract(session, projectId) {
13
+ return this.context.api.get(`${this.base(session)}/projects/${projectId}/intake/export`);
14
+ }
15
+ reviewQueue(session, projectId) {
16
+ return this.context.api.get(`${this.base(session)}/projects/${projectId}/intake/requests`);
17
+ }
18
+ decide(session, projectId, taskId, input) {
19
+ return this.context.api.request('POST', `${this.base(session)}/projects/${projectId}/intake/requests/${taskId}/decision`, input);
20
+ }
21
+ async revoke(session, projectId) {
22
+ await this.context.api.delete(`${this.base(session)}/projects/${projectId}/intake`);
23
+ }
24
+ base(session) {
25
+ return `/v1/workspaces/${session.workspace.id}`;
26
+ }
27
+ }
@@ -102,7 +102,7 @@ export class ReplicaWorkspaceService {
102
102
  }
103
103
  async members(session) {
104
104
  const members = await this.context.repos.workspaces.listMembers(session.workspace.id);
105
- return members.map(({ id, username, displayName, role }) => ({ id, username, displayName, role }));
105
+ return members.map(({ id, username, displayName, role, designated }) => ({ id, username, displayName, role, designated: role === 'owner' || Boolean(designated) }));
106
106
  }
107
107
  async findMember(session, username) {
108
108
  const normalized = username.trim().replace(/^@/, '').toLowerCase();
@@ -116,6 +116,11 @@ export class ReplicaWorkspaceService {
116
116
  await this.context.store.upsertMember(session.workspace.id, member);
117
117
  return member;
118
118
  }
119
+ async setDesignated(session, userId, designated) {
120
+ const members = await online('Updating workspace designates', () => this.context.api.request(designated ? 'POST' : 'DELETE', `/v1/workspaces/${session.workspace.id}/designates/${userId}`));
121
+ for (const member of members)
122
+ await this.context.store.upsertMember(session.workspace.id, member);
123
+ }
119
124
  }
120
125
  export class ReplicaProjectService {
121
126
  context;
@@ -166,7 +171,7 @@ export class ReplicaProjectService {
166
171
  return this.local.get(session, project.id);
167
172
  }
168
173
  }
169
- const CHANGE_FIELDS = ['title', 'description', 'projectId', 'type', 'priority', 'status', 'assigneeId', 'requester', 'branch', 'baseBranch', 'branchStart'];
174
+ const CHANGE_FIELDS = ['title', 'description', 'projectId', 'type', 'priority', 'status', 'assigneeId', 'requester', 'remunerated', 'priceMinor', 'currencyCode', 'branch', 'baseBranch', 'branchStart'];
170
175
  /**
171
176
  * Tasks in remote mode: read and written in the local replica (instant, works
172
177
  * offline), with every write queued as an operation for the server.
@@ -4,7 +4,7 @@ export const isChatOp = (type) => !type.startsWith('task.');
4
4
  /** Task fields as stored; server views carry extra presentation fields we drop. */
5
5
  const TASK_COLUMNS = [
6
6
  'id', 'number', 'workspaceId', 'projectId', 'title', 'description', 'type', 'priority', 'status', 'assigneeId',
7
- 'creatorId', 'requester', 'branch', 'baseBranch', 'branchStart', 'createdAt', 'updatedAt', 'startedAt', 'completedAt', 'archivedAt',
7
+ 'creatorId', 'requester', 'remunerated', 'priceMinor', 'currencyCode', 'externalApprovalStatus', 'branch', 'baseBranch', 'branchStart', 'createdAt', 'updatedAt', 'startedAt', 'completedAt', 'archivedAt',
8
8
  ];
9
9
  const pickTask = (task) => Object.fromEntries(TASK_COLUMNS.map((column) => [column, task[column]]));
10
10
  /**
@@ -30,8 +30,8 @@ export class ReplicaStore {
30
30
  await this.upsertUser(member);
31
31
  await this.db
32
32
  .insert(workspaceMembers)
33
- .values({ workspaceId, userId: member.id, role: member.role })
34
- .onConflictDoUpdate({ target: [workspaceMembers.workspaceId, workspaceMembers.userId], set: { role: member.role } });
33
+ .values({ workspaceId, userId: member.id, role: member.role, designated: member.designated ?? member.role === 'owner' })
34
+ .onConflictDoUpdate({ target: [workspaceMembers.workspaceId, workspaceMembers.userId], set: { role: member.role, designated: member.designated ?? member.role === 'owner' } });
35
35
  }
36
36
  /** Server projects have no local path; the one this machine linked is kept. */
37
37
  async upsertProject(project) {
@@ -0,0 +1,5 @@
1
+ ALTER TABLE tasks ADD COLUMN remunerated INTEGER NOT NULL DEFAULT 0 CHECK (remunerated IN (0, 1));--> statement-breakpoint
2
+ ALTER TABLE tasks ADD COLUMN price_minor INTEGER CHECK (price_minor IS NULL OR price_minor >= 0);--> statement-breakpoint
3
+ ALTER TABLE tasks ADD COLUMN currency_code TEXT CHECK (currency_code IS NULL OR currency_code GLOB '[A-Z][A-Z][A-Z]');
4
+ --> statement-breakpoint
5
+ ALTER TABLE workspace_members ADD COLUMN designated INTEGER NOT NULL DEFAULT 0 CHECK (designated IN (0, 1));
@@ -0,0 +1,44 @@
1
+ PRAGMA foreign_keys=OFF;--> statement-breakpoint
2
+ CREATE TABLE `__new_tasks` (
3
+ `id` text PRIMARY KEY NOT NULL,
4
+ `number` integer NOT NULL,
5
+ `workspace_id` text NOT NULL,
6
+ `project_id` text,
7
+ `title` text NOT NULL,
8
+ `description` text,
9
+ `type` text NOT NULL,
10
+ `priority` text NOT NULL,
11
+ `status` text NOT NULL,
12
+ `assignee_id` text,
13
+ `creator_id` text NOT NULL,
14
+ `requester` text,
15
+ `remunerated` integer DEFAULT false NOT NULL,
16
+ `price_minor` integer,
17
+ `currency_code` text,
18
+ `branch` text,
19
+ `base_branch` text,
20
+ `branch_start` text,
21
+ `created_at` integer NOT NULL,
22
+ `updated_at` integer NOT NULL,
23
+ `started_at` integer,
24
+ `completed_at` integer,
25
+ `archived_at` integer,
26
+ FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade,
27
+ FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE set null,
28
+ FOREIGN KEY (`assignee_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
29
+ FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
30
+ CONSTRAINT "tasks_type" CHECK(type IN ('bug', 'feature', 'improvement', 'maintenance', 'infra', 'refactor', 'research', 'chore')),
31
+ CONSTRAINT "tasks_priority" CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')),
32
+ CONSTRAINT "tasks_status" CHECK(status IN ('backlog', 'todo', 'in_progress', 'review', 'blocked', 'done', 'cancelled')),
33
+ CONSTRAINT "tasks_price_nonnegative" CHECK(price_minor IS NULL OR price_minor >= 0),
34
+ CONSTRAINT "tasks_remuneration_fields" CHECK((remunerated = 0 AND price_minor IS NULL AND currency_code IS NULL) OR (remunerated = 1 AND price_minor IS NOT NULL AND currency_code IS NOT NULL))
35
+ );
36
+ --> statement-breakpoint
37
+ INSERT INTO `__new_tasks`("id", "number", "workspace_id", "project_id", "title", "description", "type", "priority", "status", "assignee_id", "creator_id", "requester", "remunerated", "price_minor", "currency_code", "branch", "base_branch", "branch_start", "created_at", "updated_at", "started_at", "completed_at", "archived_at") SELECT "id", "number", "workspace_id", "project_id", "title", "description", "type", "priority", "status", "assignee_id", "creator_id", "requester", "remunerated", "price_minor", "currency_code", "branch", "base_branch", "branch_start", "created_at", "updated_at", "started_at", "completed_at", "archived_at" FROM `tasks`;--> statement-breakpoint
38
+ DROP TABLE `tasks`;--> statement-breakpoint
39
+ ALTER TABLE `__new_tasks` RENAME TO `tasks`;--> statement-breakpoint
40
+ PRAGMA foreign_keys=ON;--> statement-breakpoint
41
+ CREATE UNIQUE INDEX `tasks_workspace_number` ON `tasks` (`workspace_id`,`number`);--> statement-breakpoint
42
+ CREATE INDEX `tasks_workspace_status` ON `tasks` (`workspace_id`,`status`);--> statement-breakpoint
43
+ CREATE INDEX `tasks_assignee` ON `tasks` (`assignee_id`);--> statement-breakpoint
44
+ CREATE INDEX `tasks_project` ON `tasks` (`project_id`);--> statement-breakpoint
@@ -0,0 +1 @@
1
+ ALTER TABLE `tasks` ADD `external_approval_status` text;