Changes
13 changed files (+1421/-28)
-
-
@@ -22,7 +22,7 @@ }export async function idbBackend(): Promise<IDBBackend> { return new IDBBackend({ db: await openDB("yamori", 7, { db: await openDB("yamori", 8, { // TODO: マイグレーションを個別のファイルに切り分ける async upgrade(db, oldVersion, _newVersion, transaction, _event) { // v1
-
@@ -210,6 +210,31 @@ };}), }); } } // v8 if (oldVersion < 8) { const store = transaction.objectStore("workers"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, writeWorkRecordKey: cursor.value.writeWorkRecordKey || createRandomBytes(16), }); } const workRecords = db.createObjectStore("workRecords", { keyPath: "recordId", }); workRecords.createIndex( "workspaceId/workerId/datePacked", ["workspaceId", "workerId", "datePacked"], { unique: true, }, ); } }, }),
-
-
-
@@ -44,10 +44,53 @@ workspaceId: string;displayName: string; createdAt: number; updatedAt: number; writeWorkRecordKey: Uint8Array; }; indexes: { workspaceId: string; updatedAt: number; }; }; workRecords: { key: string; value: { recordId: string; workerId: string; workspaceId: string; datePacked: number; note?: string; record?: | { type: "working_day"; hasWorked: boolean; timeOffs: ( | { type: "halved_paid_leave"; } | { type: "hourly_paid_leave"; hours: number; } )[]; } | { type: "day_off"; } | { type: "paid_leave"; } | { type: "legal_leave"; leave_id: string; } | { type: "special_leave"; leave_id: string; }; }; indexes: { "workspaceId/workerId/datePacked": [string, string, number]; }; }; }
-
-
-
@@ -6,11 +6,22 @@ import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js";import { type LeaveRevisionSchema } from "@yamori/proto/yamori/work_record/v1/leave_revision_pb.js"; import { type LeaveReadMask } from "@yamori/proto/yamori/work_record/v1/leave_read_mask_pb.js"; import { maskMessage, unpackDate } from "../../../helpers"; import { maskMessage, packDate, unpackDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; function now(): number { const d = new Date(); return packDate({ year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), }); } export function fromDBEntry( entry: YamoriDB["workspaces"]["value"]["leaveDefinitions"][number], date: number = now(), ): MessageShape<typeof LeaveSchema> { const revisions = entry.revisions.map<MessageInitShape<typeof LeaveRevisionSchema>>( (raw) => {
-
@@ -24,23 +35,9 @@ };}, ); // 比較対象の両方の日付を現地時間に変換するためタイムゾーンを気にする必要はない。 // TODO: DST に対応できるか検証 const now = new Date(); let currentRevision: MessageInitShape<typeof LeaveRevisionSchema> | null = null; for (const rev of revisions) { if ( !rev.startAt || typeof rev.startAt.year !== "number" || typeof rev.startAt.month !== "number" || typeof rev.startAt.day !== "number" ) { continue; } const startAt = new Date(rev.startAt.year, rev.startAt.month - 1, rev.startAt.day); if (now >= startAt) { if (date >= packDate(rev.startAt!)) { currentRevision = rev; }
-
-
-
@@ -0,0 +1,266 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, type MessageShape, type MessageInitShape } from "@bufbuild/protobuf"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; import { WorkRecordBatchWriteMaskSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_mask_pb.js"; import { type WorkRecordReadMask } from "@yamori/proto/yamori/work_record/v1/work_record_read_mask_pb.js"; import { TimeOffSchema } from "@yamori/proto/yamori/work_record/v1/time_off_pb.js"; import { maskMessage, unpackDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import * as leave from "./leave"; const { field } = WorkRecordBatchWriteInputSchema; function toDBEntryRecord( base: YamoriDB["workRecords"]["value"], message: MessageShape<typeof WorkRecordBatchWriteInputSchema>, mask?: MessageShape<typeof WorkRecordBatchWriteMaskSchema>, ): YamoriDB["workRecords"]["value"]["record"] { switch (message.record.case) { case "dayOff": if (mask && !mask.fields.includes(field.dayOff.number)) { return base.record; } return { type: "day_off", }; case "workingDay": if (mask && !mask.fields.includes(field.workingDay.number)) { return base.record; } return { type: "working_day", hasWorked: message.record.value.hasWorkerWorked, timeOffs: message.record.value.timeOffs.map((timeOff) => { switch (timeOff.kind.case) { case "hourlyPaidLeave": return { type: "hourly_paid_leave", hours: timeOff.kind.value.hours, }; case "halvedPaidLeave": return { type: "halved_paid_leave", }; default: throw new Error(`Unsupported time_off type: ${timeOff.kind.case}`); } }), }; case "paidLeave": if (mask && !mask.fields.includes(field.paidLeave.number)) { return base.record; } return { type: "paid_leave", }; case "legalLeaveId": if (mask && !mask.fields.includes(field.legalLeaveId.number)) { return base.record; } return { type: "legal_leave", leave_id: message.record.value.value, }; case "specialLeaveId": if (mask && !mask.fields.includes(field.specialLeaveId.number)) { return base.record; } return { type: "special_leave", leave_id: message.record.value.value, }; default: switch (base.record?.type) { case "day_off": return !mask || mask.fields.includes(field.dayOff.number) ? undefined : base.record; case "paid_leave": return !mask || mask.fields.includes(field.paidLeave.number) ? undefined : base.record; case "working_day": return !mask || mask.fields.includes(field.workingDay.number) ? undefined : base.record; case "legal_leave": return !mask || mask.fields.includes(field.legalLeaveId.number) ? undefined : base.record; case "special_leave": return !mask || mask.fields.includes(field.specialLeaveId.number) ? undefined : base.record; default: return base.record; } } } export function toDBEntry( base: YamoriDB["workRecords"]["value"], message: MessageShape<typeof WorkRecordBatchWriteInputSchema>, mask?: MessageShape<typeof WorkRecordBatchWriteMaskSchema>, ): YamoriDB["workRecords"]["value"] { return { recordId: base.recordId, workerId: base.workerId, workspaceId: base.workspaceId, record: toDBEntryRecord(base, message, mask), note: !mask || mask.fields.includes(WorkRecordBatchWriteInputSchema.field.note.number) ? message.note : base.note, datePacked: base.datePacked, }; } function getLeave( id: string, datePacked: number, workspace: YamoriDB["workspaces"]["value"], ): MessageShape<typeof LeaveSchema> | undefined { const found = workspace.leaveDefinitions.find((def) => def.id === id); if (!found) { return undefined; } return leave.fromDBEntry(found, datePacked); } export function fromDBEntry( entry: YamoriDB["workRecords"]["value"], workspace: YamoriDB["workspaces"]["value"], ): MessageShape<typeof WorkRecordSchema> { switch (entry.record?.type) { case "day_off": return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: { case: "dayOff", value: {}, }, }); case "working_day": return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: { case: "workingDay", value: { hasWorkerWorked: entry.record.hasWorked, timeOffs: entry.record.timeOffs .map<MessageInitShape<typeof TimeOffSchema> | null>((timeOff) => { switch (timeOff.type) { case "hourly_paid_leave": return { kind: { case: "hourlyPaidLeave", value: { hours: timeOff.hours, }, }, }; case "halved_paid_leave": return { kind: { case: "halvedPaidLeave", value: { // TS の型推論がおもちゃなのでこれがないと意味不明な型を // 当てはめようとしてエラーになる。 providedAt: undefined, }, }, }; default: return null; } }) .filter((t): t is NonNullable<typeof t> => !!t), }, }, }); case "paid_leave": return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: { case: "paidLeave", value: {}, }, }); case "special_leave": { const leave = getLeave(entry.record.leave_id, entry.datePacked, workspace); return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: leave && { case: "specialLeave", value: leave, }, }); } case "legal_leave": { const leave = getLeave(entry.record.leave_id, entry.datePacked, workspace); return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, record: leave && { case: "legalLeave", value: leave, }, }); } default: return create(WorkRecordSchema, { date: unpackDate(entry.datePacked), note: entry.note, }); } } export function mask( mask: WorkRecordReadMask | undefined, workRecord: MessageInitShape<typeof WorkRecordSchema>, ): MessageShape<typeof WorkRecordSchema> { if (!mask) { return create(WorkRecordSchema, workRecord); } const masked = maskMessage(WorkRecordSchema, mask, workRecord); switch (masked.record.case) { case "legalLeave": return create(WorkRecordSchema, { ...masked, record: { case: "legalLeave", value: leave.mask(mask.legalLeaveReadMask, masked.record.value), }, }); case "specialLeave": return create(WorkRecordSchema, { ...masked, record: { case: "specialLeave", value: leave.mask(mask.specialLeaveReadMask, masked.record.value), }, }); default: return masked; } }
-
-
-
@@ -7,13 +7,18 @@ import { type WorkerReadMaskSchema } from "@yamori/proto/yamori/worker/v1/worker_read_mask_pb.js";import { maskMessage } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import * as workRecord from "../../work_record/v1/work_record"; export function fromDBEntry( entry: YamoriDB["workers"]["value"], workspace: YamoriDB["workspaces"]["value"], workRecords: YamoriDB["workRecords"]["value"][], ): MessageShape<typeof WorkerSchema> { return create(WorkerSchema, { id: { value: entry.id }, displayName: entry.displayName, writeWorkRecordKey: { key: entry.writeWorkRecordKey }, workRecords: workRecords.map((record) => workRecord.fromDBEntry(record, workspace)), }); }
-
@@ -25,5 +30,12 @@ if (!mask) {return create(WorkerSchema, worker); } return maskMessage(WorkerSchema, mask, worker); const masked = maskMessage(WorkerSchema, mask, worker); return create(WorkerSchema, { ...masked, workRecords: masked.workRecords.map((record) => workRecord.mask(mask.workRecordsMask, record), ), }); }
-
-
-
@@ -12,6 +12,8 @@ type MessageInitShape,} from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js";
-
@@ -54,3 +56,28 @@ }return resp.result.value.worker; } export async function getWorker( ctx: Context, payload: MessageInitShape<typeof GetRequestSchema>, ): Promise<MessageShape<typeof WorkerSchema>> { const resp = fromBinary( GetResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "Get", data: toBinary(GetRequestSchema, create(GetRequestSchema, payload)), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for worker retrieve, got ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; }
-
-
-
@@ -13,8 +13,8 @@ type CreateRequest,} from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import { isSameBytes, createRandomBytes } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as worker from "../worker"; function respond(
-
@@ -73,6 +73,7 @@ }// TODO: Handle idempotency_key (how?) let workspace: YamoriDB["workspaces"]["value"]; try { const found = await db.get("workspaces", req.workspaceId.value); if (!found) {
-
@@ -84,6 +85,8 @@ },}); } workspace = found; if (!isSameBytes(req.workerAddKey.key, found.capabilities.workerAddKey)) { return respond({ case: "capabilityError",
-
@@ -116,6 +119,7 @@ workspaceId: req.workspaceId.value,displayName: req.displayName, createdAt: Date.now(), updatedAt: Date.now(), writeWorkRecordKey: createRandomBytes(16), }); const added = await tx.store.get(key);
-
@@ -128,7 +132,7 @@return respond({ case: "ok", value: { worker: worker.mask(req.readMask, worker.fromDBEntry(added)), worker: worker.mask(req.readMask, worker.fromDBEntry(added, workspace, [])), }, }); } catch (error) {
-
-
-
@@ -10,7 +10,9 @@ type MessageShape,} from "@bufbuild/protobuf"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as worker from "../worker";
-
@@ -93,9 +95,33 @@ },}); } const now = new Date(); const today = { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), }; const since = packDate(req.workRecordFilter?.since ?? today); const until = packDate(req.workRecordFilter?.until ?? today); const recordRange = IDBKeyRange.bound( [workspace.id, found.id, since], [workspace.id, found.id, until], ); const records = !req.readMask || req.readMask.fields.includes(WorkerSchema.field.workRecords.number) ? await db.getAllFromIndex( "workRecords", "workspaceId/workerId/datePacked", recordRange, ) : []; return respond({ case: "ok", value: worker.mask(req.readMask, worker.fromDBEntry(found)), value: worker.mask(req.readMask, worker.fromDBEntry(found, workspace, records)), }); } catch (error) { return respond({
-
-
-
@@ -9,6 +9,7 @@ } from "@yamori/proto/yamori/worker/v1/list_request_pb.js";import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as worker from "../worker";
-
@@ -72,14 +73,30 @@ });} try { const tx = db.transaction("workers", "readonly"); const index = tx.store.index("workspaceId"); const now = new Date(); const today = { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), }; const workers: Worker[] = (await index.getAll(IDBKeyRange.only(workspace.id))) .sort((a, b) => b.updatedAt - a.updatedAt) .map((w) => worker.mask(req.readMask, worker.fromDBEntry(w))); const since = packDate(req.workRecordFilter?.since ?? today); const until = packDate(req.workRecordFilter?.until ?? today); await tx.done; const workers: Worker[] = await Promise.all( (await db.getAllFromIndex("workers", "workspaceId", workspace.id)) .sort((a, b) => b.updatedAt - a.updatedAt) .map(async (w) => { const recordRange = IDBKeyRange.bound( [workspace.id, w.id, since], [workspace.id, w.id, until], ); const records = await db.getAll("workRecords", recordRange); return worker.mask(req.readMask, worker.fromDBEntry(w, workspace, records)); }), ); return respond({ case: "ok",
-
-
-
@@ -6,6 +6,7 @@import { create } from "./create"; import { get } from "./get"; import { list } from "./list"; import { writeWorkRecord } from "./write_work_record"; export async function workerService( request: RPCMessage,
-
@@ -18,6 +19,8 @@ case "List":return list(request.data, ctx); case "Get": return get(request.data, ctx); case "WriteWorkRecord": return writeWorkRecord(request.data, ctx); default: throw new Error(`Unknown method "${request.method}" for ${request.service}`); }
-
-
-
@@ -0,0 +1,707 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageInitShape, type MessageShape, } from "@bufbuild/protobuf"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; import { WriteWorkRecordRequestSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_request_pb.js"; import { WriteWorkRecordResponseSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { createWorkspace, createLeaveDefinition, } from "../../../workspace/v1/workspace_service/_test_utils"; import { createWorker, getWorker } from "./_test_utils"; import { workerService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function writeWorkRecord( ctx: Context, request: MessageInitShape<typeof WriteWorkRecordRequestSchema>, ): Promise<MessageShape<typeof WriteWorkRecordResponseSchema>> { return fromBinary( WriteWorkRecordResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "WriteWorkRecord", data: toBinary( WriteWorkRecordRequestSchema, create(WriteWorkRecordRequestSchema, request), ), }, ctx, ), ); } for (const field of [ "workspaceId", "workerId", "writeWorkRecordKey", "workRecord", ] as const) { const fieldName = WriteWorkRecordRequestSchema.field[field].name; test(`Should return missing_field if ${fieldName} is empty`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: field === "workspaceId" ? undefined : workspace.id, workerId: field === "workerId" ? undefined : worker.id, writeWorkRecordKey: field === "writeWorkRecordKey" ? undefined : worker.writeWorkRecordKey, workRecord: field === "workRecord" ? undefined : { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe(fieldName); }); } test("Should return not_found if workspace does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: { value: workspace.id?.value + "-000", }, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return not_found if worker does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace(ctx); const bar = await createWorkspace(ctx); const worker = await createWorker(ctx, foo); const resp = await writeWorkRecord(ctx, { workspaceId: bar.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.Worker"); }); test("Should reject malformed capability key", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: { key: new Uint8Array([0, 0, 0]) }, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe( WriteWorkRecordRequestSchema.field.writeWorkRecordKey.name, ); }); test("Should reject nonexistent leave_id", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "legalLeaveId", value: { value: leave.id?.value + "-000" }, }, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test("Should reject reference to leave not started yet", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 2 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2024, month: 1, day: 2 }, ], record: { case: "legalLeaveId", value: leave.id!, }, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test("Should write a note", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workRecords).toEqual([ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), note: "Foo", }), ]); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 1, }, }, readMask: { fields: [WorkerSchema.field.workRecords.number], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), note: "Foo", }), ], }); }); test("Should write a record", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "specialLeaveId", value: leave.id!, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workRecords).toEqual([ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "specialLeave", value: expect.objectContaining({ id: leave.id, }), }), }), ]); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 1, }, }, readMask: { fields: [WorkerSchema.field.workRecords.number], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "specialLeave", value: expect.objectContaining({ id: leave.id, }), }), }), ], }); }); test("Should write to multiple dates", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2024, month: 1, day: 2 }, ], record: { case: "workingDay", value: { hasWorkerWorked: true, timeOffs: [], }, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workRecords).toEqual([ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), ]); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 2, }, }, readMask: { fields: [WorkerSchema.field.workRecords.number], }, }); expect(after.workRecords).toHaveLength(2); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), ], }); }); test("Should write to specified user", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const alice = await createWorker(ctx, workspace); const bob = await createWorker(ctx, workspace); const resp1 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: alice.id, writeWorkRecordKey: alice.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "dayOff", value: {}, }, }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } const resp2 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: bob.id, writeWorkRecordKey: bob.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 2 }], note: "Holiday", }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } const aliceAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: alice.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 3, }, }, }); const bobAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: bob.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 3, }, }, }); expect(aliceAfter).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "dayOff", value: expect.anything(), }), }), ], }); expect(bobAfter).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), note: "Holiday", }), ], }); }); test("Should overwrite respecting mask", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp1 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "dayOff", value: {}, }, note: "Foo", }, writeMask: { fields: [WorkRecordBatchWriteInputSchema.field.dayOff.number], }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } expect(resp1.result.value.workRecords).toHaveLength(1); expect(resp1.result.value.workRecords[0]).toMatchObject({ record: { case: "dayOff", value: {}, }, }); expect(resp1.result.value.workRecords[0]!.note).toBeEmpty(); const resp2 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, writeMask: { fields: [WorkRecordBatchWriteInputSchema.field.note.number], }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } expect(resp2.result.value.workRecords).toHaveLength(1); expect(resp2.result.value.workRecords[0]).toMatchObject({ record: { case: "dayOff", value: {}, }, }); expect(resp2.result.value.workRecords[0]!.note).toBe("Foo"); const resp3 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], }, }); if (resp3.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp3.result.case)}`); } expect(resp3.result.value.workRecords).toHaveLength(1); expect(resp3.result.value.workRecords[0]!.record.case).toBeEmpty(); expect(resp3.result.value.workRecords[0]!.note).toBeEmpty(); });
-
-
-
@@ -0,0 +1,234 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape, type MessageShape, } from "@bufbuild/protobuf"; import { WriteWorkRecordRequestSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_request_pb.js"; import { WriteWorkRecordResponseSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_response_pb.js"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; import { isSameBytes, packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; function respond( result: Exclude< NonNullable<MessageInitShape<typeof WriteWorkRecordResponseSchema>["result"]>, { case: undefined } >, ): Uint8Array { return toBinary( WriteWorkRecordResponseSchema, create(WriteWorkRecordResponseSchema, { result }), ); } export async function writeWorkRecord( data: Uint8Array, { db }: Context, ): Promise<Uint8Array> { let req: MessageShape<typeof WriteWorkRecordRequestSchema>; try { req = fromBinary(WriteWorkRecordRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } if (!req.workspaceId) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.workspaceId.name, }, }); } if (!req.workerId) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.workerId.name, }, }); } if (!req.writeWorkRecordKey) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.writeWorkRecordKey.name, }, }); } if (!req.workRecord) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.workRecord.name, }, }); } if (!req.workRecord.dates.length) { return respond({ case: "missingField", value: { path: [ WriteWorkRecordRequestSchema.field.workRecord.name, WorkRecordBatchWriteInputSchema.field.dates.name, ].join("."), }, }); } let workspace: YamoriDB["workspaces"]["value"]; try { const found = await db.get("workspaces", req.workspaceId.value); if (!found) { return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } if ( req.workRecord.record.case === "legalLeaveId" || req.workRecord.record.case === "specialLeaveId" ) { const leaveId = req.workRecord.record.value.value; const match = found.leaveDefinitions.find((def) => def.id === leaveId); if (!match) { return respond({ case: "notFound", value: { typeName: "yamori.work_record.v1.Leave", }, }); } for (const date of req.workRecord.dates) { const packed = packDate(date); if (!match.revisions.some((rev) => rev.startAtPackedDate <= packed)) { // TODO: ちょっと適切じゃない気がするので、エラーの追加含めて検討 return respond({ case: "notFound", value: { typeName: "yamori.work_record.v1.Leave", }, }); } } } workspace = found; } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during workspace lookup" : `Exception thrown during workspace lookup: ${error}`, }, }); } try { const worker = await db.get("workers", req.workerId.value); if (!worker || worker.workspaceId !== req.workspaceId.value) { return respond({ case: "notFound", value: { typeName: "yamori.worker.v1.Worker", }, }); } if (!isSameBytes(worker.writeWorkRecordKey, req.writeWorkRecordKey.key)) { return respond({ case: "capabilityError", value: { path: WriteWorkRecordRequestSchema.field.writeWorkRecordKey.name, }, }); } } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during worker lookup" : `Exception thrown during worker lookup: ${error}`, }, }); } try { const tx = db.transaction("workRecords", "readwrite"); const added: YamoriDB["workRecords"]["value"][] = []; for (const date of req.workRecord.dates) { const packed = packDate(date); const existing = await tx.store .index("workspaceId/workerId/datePacked") .get([req.workspaceId.value, req.workerId.value, packed]); const payload = workRecord.toDBEntry( existing || { recordId: crypto.randomUUID(), workerId: req.workerId.value, workspaceId: req.workspaceId.value, datePacked: packed, }, req.workRecord, req.writeMask, ); tx.store.put(payload); added.push(payload); } await tx.done; return respond({ case: "ok", value: { workRecords: added.map((entry) => workRecord.fromDBEntry(entry, workspace)), }, }); } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during writing work record" : `Exception thrown during work record writes: ${error}`, }, }); } }
-
-
-
@@ -12,9 +12,12 @@ type MessageInitShape,} from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type Context } from "../../../../types";
-
@@ -84,3 +87,32 @@ }return resp.result.value; } export async function createLeaveDefinition( ctx: Context, request: MessageInitShape<typeof CreateLeaveDefinitionRequestSchema>, ): Promise<MessageShape<typeof LeaveSchema>> { const resp = fromBinary( CreateLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", data: toBinary( CreateLeaveDefinitionRequestSchema, create(CreateLeaveDefinitionRequestSchema, request), ), }, ctx, ), ); if (resp.result.case !== "ok") { console.dir(resp.result.value); expect.unreachable( `Expected "ok" for creating leave definition, got ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; }
-