Skip to content

Commit 1bc04dd

Browse files
OUT-4029 | Stop generating task labels; keep label for backward compatibility
Stops generating per-task label codes and deletes LabelMappingService and the Labels registry table. Keeps the Tasks.label column (now @default('')) so the public automation API stays backward compatible: pre-existing tasks keep their codes, new tasks return ''. - Public API: label retained in PublicTaskDtoSchema + serializer, with a serializer test locking the contract (empty for new, preserved for old). - Internal app no longer uses label — drag preview shows the workflow-state icon, the breadcrumb shows the task title, the search filter drops the label matcher. - Breadcrumb: desktop truncates the title at 25 chars; below 600px it uses a shorter 12-char title to avoid the platform header overflowing. - Migration (edited in place): set Tasks.label DEFAULT '' and DROP TABLE Labels (the column is kept, not dropped). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 25204a1 commit 1bc04dd

19 files changed

Lines changed: 101 additions & 299 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "Tasks" ALTER COLUMN "label" SET DEFAULT '';
2+
DROP TABLE "Labels";

prisma/schema/label.prisma

Lines changed: 0 additions & 12 deletions
This file was deleted.

prisma/schema/task.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ enum Source {
1111

1212
model Task {
1313
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
14-
label String
14+
label String @default("")
1515
workspaceId String @db.VarChar(32)
1616
assigneeId String? @db.Uuid
1717
internalUserId String? @db.Uuid

src/app/api/label-mapping/label-mapping.service.ts

Lines changed: 0 additions & 171 deletions
This file was deleted.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: class {} }))
2+
jest.mock('@/lib/db', () => ({ __esModule: true, default: { getInstance: () => ({}) } }))
3+
jest.mock('@/app/api/attachments/public/public.serializer', () => ({
4+
PublicAttachmentSerializer: { serializeAttachments: jest.fn(async () => []) },
5+
}))
6+
jest.mock('@/utils/santizeContents', () => ({ sanitizeHtml: (value: string) => value }))
7+
jest.mock('@/utils/signedUrlReplacer', () => ({
8+
replaceMediaSources: jest.fn(async (value: string) => value),
9+
replaceImageSrc: jest.fn(async (value: string) => value),
10+
}))
11+
jest.mock('@/utils/signUrl', () => ({ getSignedUrl: jest.fn(async (value: string) => value) }))
12+
jest.mock('@/utils/signedTemplateUrlReplacer', () => ({
13+
copyTemplateMediaToTask: jest.fn(async (_workspaceId: string, value: string) => value),
14+
}))
15+
16+
import { PublicTaskSerializer, TaskWithWorkflowStateAndAttachments } from './public.serializer'
17+
18+
const IU_ID = '22222222-2222-2222-2222-222222222222'
19+
20+
const makeTask = (overrides: Partial<TaskWithWorkflowStateAndAttachments> = {}): TaskWithWorkflowStateAndAttachments =>
21+
({
22+
id: '11111111-1111-1111-1111-111111111111',
23+
title: 'A task',
24+
body: null,
25+
parentId: null,
26+
dueDate: null,
27+
label: '',
28+
templateId: null,
29+
createdById: IU_ID,
30+
completedAt: null,
31+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
32+
isArchived: false,
33+
lastArchivedDate: null,
34+
archivedBy: null,
35+
deletedAt: null,
36+
source: 'api',
37+
deletedBy: null,
38+
completedBy: null,
39+
completedByUserType: null,
40+
internalUserId: IU_ID,
41+
clientId: null,
42+
companyId: null,
43+
associations: [],
44+
isShared: false,
45+
workflowState: { type: 'started' },
46+
attachments: [],
47+
...overrides,
48+
}) as unknown as TaskWithWorkflowStateAndAttachments
49+
50+
// OUT-4029: label generation was removed but the public API keeps the `label`
51+
// field for backward compatibility — empty string for new tasks, the stored
52+
// value for pre-existing ones. These lock that contract so a future change
53+
// can't silently drop the field again.
54+
describe('PublicTaskSerializer — label backward compatibility', () => {
55+
it('returns an empty-string label for tasks created after label generation was removed', async () => {
56+
const result = await PublicTaskSerializer.serializeUnsafe(makeTask({ label: '' }))
57+
expect(result.label).toBe('')
58+
})
59+
60+
it('preserves the pre-existing generated label for older tasks', async () => {
61+
const result = await PublicTaskSerializer.serializeUnsafe(makeTask({ label: 'OUT-001' }))
62+
expect(result.label).toBe('OUT-001')
63+
})
64+
65+
it('keeps label in the schema-validated public DTO', async () => {
66+
const result = await PublicTaskSerializer.serialize(makeTask({ label: 'OUT-001' }))
67+
expect(result.label).toBe('OUT-001')
68+
})
69+
})

src/app/api/tasks/public/public.service.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { AssigneeType, Prisma, PrismaClient, Source, StateType, Task, TaskTempla
2020
import httpStatus from 'http-status'
2121
import z from 'zod'
2222
import APIError from '@api/core/exceptions/api'
23-
import { LabelMappingService } from '@api/label-mapping/label-mapping.service'
2423
import { SubtaskService } from '@api/tasks/subtasks.service'
2524
import { TasksActivityLogger } from '@api/tasks/tasks.logger'
2625
import { TemplatesService } from '@api/tasks/templates/templates.service'
@@ -143,10 +142,6 @@ export class PublicTasksService extends TasksSharedService {
143142
companyId: validatedIds.companyId,
144143
})
145144

146-
//generate the label
147-
const labelMappingService = new LabelMappingService(this.user)
148-
const label = z.string().parse(await labelMappingService.getLabel(validatedIds))
149-
console.info('PublicTasksService#createTask | Generated label for task:', label)
150145
if (data.parentId) {
151146
const canCreateSubTask = await this.canCreateSubTask(data.parentId)
152147
if (!canCreateSubTask) {
@@ -190,7 +185,6 @@ export class PublicTasksService extends TasksSharedService {
190185
...data,
191186
workspaceId: this.user.workspaceId,
192187
createdById,
193-
label: label,
194188
completedBy,
195189
completedByUserType,
196190
source: Source.api,
@@ -364,16 +358,6 @@ export class PublicTasksService extends TasksSharedService {
364358
const subtaskService = new SubtaskService(this.user)
365359

366360
let updatedTask = await this.db.$transaction(async (tx) => {
367-
//generate new label if prevTask has no assignee but now assigned to someone
368-
let label: string = prevTask.label
369-
if (!prevTask.assigneeId && assigneeId && assigneeType) {
370-
const labelMappingService = new LabelMappingService(this.user)
371-
labelMappingService.setTransaction(tx as PrismaClient)
372-
if (validatedIds) {
373-
label = z.string().parse(await labelMappingService.getLabel(validatedIds))
374-
}
375-
}
376-
377361
// Set / reset lastArchivedDate if isArchived has been triggered, else remove it from the update query
378362
let lastArchivedDate: Date | undefined | null = undefined
379363
let archivedBy: string | null | undefined = undefined
@@ -387,7 +371,6 @@ export class PublicTasksService extends TasksSharedService {
387371
where: { id },
388372
data: {
389373
...dataWithoutUserIds,
390-
label,
391374
lastArchivedDate,
392375
archivedBy,
393376
completedBy,

src/app/api/tasks/subtasks.service.integration.test.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,20 @@ describe('SubtaskService.softDeleteAllSubtasks (integration)', () => {
2121
beforeEach(truncateAll)
2222
afterAll(disconnectTestDb)
2323

24-
// Reproduces OUT-4029: colliding label strings across workspaces must not cause cross-workspace deletes.
25-
it('deletes only the acting workspace subtree when a descendant shares a label string with another workspace', async () => {
24+
// OUT-4029: deletion is scoped by id + workspaceId. A task in another workspace must
25+
// never be soft-deleted when we delete the acting workspace's subtree.
26+
it('deletes only the acting workspace subtree and leaves other workspaces untouched', async () => {
2627
const workspaceA = 'ws-a'
2728
const workspaceB = 'ws-b'
28-
const sharedLabel = 'THE10-001'
2929

30-
// Root is soft-deleted before softDeleteAllSubtasks runs (as in the real call sites), so
31-
// the query returns only live descendants — the collision is on childA.
32-
const rootA = await seedTask({ workspaceId: workspaceA, label: 'THE10-000', deletedAt: new Date() })
33-
const childA = await seedTask({ workspaceId: workspaceA, parentId: rootA, label: sharedLabel })
30+
// Root is soft-deleted before softDeleteAllSubtasks runs (as at the real call sites),
31+
// so the descendant query returns only the live child.
32+
const rootA = await seedTask({ workspaceId: workspaceA, deletedAt: new Date() })
33+
const childA = await seedTask({ workspaceId: workspaceA, parentId: rootA })
3434
await setPath(rootA, rootA)
3535
await setPath(childA, rootA, childA)
3636

37-
// Different workspace, different id, but the SAME label string as descendant childA.
38-
const taskB = await seedTask({ workspaceId: workspaceB, label: sharedLabel })
37+
const taskB = await seedTask({ workspaceId: workspaceB })
3938
await setPath(taskB, taskB)
4039

4140
await new SubtaskService(makeUser(workspaceA)).softDeleteAllSubtasks(rootA)
@@ -47,7 +46,6 @@ describe('SubtaskService.softDeleteAllSubtasks (integration)', () => {
4746
])
4847

4948
expect(ca?.deletedAt).not.toBeNull()
50-
// The colliding-label task in the other workspace must survive.
5149
expect(b?.deletedAt).toBeNull()
5250
})
5351
})

src/app/api/tasks/subtasks.service.test.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
const mockQueryRawUnsafe = jest.fn()
22
const mockTaskDeleteMany = jest.fn()
3-
const mockLabelDeleteMany = jest.fn()
43

54
jest.mock('@/lib/db', () => ({
65
__esModule: true,
76
default: {
87
getInstance: () => ({
98
$queryRawUnsafe: mockQueryRawUnsafe,
109
task: { deleteMany: mockTaskDeleteMany },
11-
label: { deleteMany: mockLabelDeleteMany },
1210
}),
1311
},
1412
}))
@@ -38,12 +36,4 @@ describe('SubtaskService#softDeleteAllSubtasks', () => {
3836
where: { id: { in: ['task-a', 'task-b'] }, workspaceId: 'ws-1' },
3937
})
4038
})
41-
42-
it('never deletes Labels registry rows', async () => {
43-
mockQueryRawUnsafe.mockResolvedValue([{ id: 'task-a' }])
44-
45-
await new SubtaskService(user).softDeleteAllSubtasks('parent-id')
46-
47-
expect(mockLabelDeleteMany).not.toHaveBeenCalled()
48-
})
4939
})

src/app/api/tasks/subtasks.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export class SubtaskService extends BaseService {
8888
)
8989
).map((row) => row.id)
9090

91-
// Scope by id + workspaceId: label strings collide across workspaces (Labels has no workspaceId).
91+
// Scope by id + workspaceId so a delete never crosses into another workspace's tasks.
9292
await this.db.task.deleteMany({ where: { id: { in: subtaskIds }, workspaceId: this.user.workspaceId } })
9393
}
9494

0 commit comments

Comments
 (0)