Changes
14 changed files (+1409/-37)
-
-
@@ -22,7 +22,7 @@ }export async function idbBackend(): Promise<IDBBackend> { return new IDBBackend({ db: await openDB("yamori", 8, { db: await openDB("yamori", 9, { // TODO: マイグレーションを個別のファイルに切り分ける async upgrade(db, oldVersion, _newVersion, transaction, _event) { // v1
-
@@ -234,6 +234,35 @@ ["workspaceId", "workerId", "datePacked"],{ unique: true, }, ); } // v9 if (oldVersion < 9) { const store = transaction.objectStore("workers"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, providePaidLeaveKey: cursor.value.providePaidLeaveKey || createRandomBytes(16), }); } const paidLeaveProvision = db.createObjectStore("paidLeaveProvision", { keyPath: "id", }); paidLeaveProvision.createIndex( "workspaceId,workerId", ["workspaceId", "workerId"], { unique: false }, ); paidLeaveProvision.createIndex( "workspaceId,workerId,providedAtPacked", ["workspaceId", "workerId", "providedAtPacked"], { unique: true }, ); } },
-
-
-
@@ -45,6 +45,7 @@ displayName: string;createdAt: number; updatedAt: number; writeWorkRecordKey: Uint8Array; providePaidLeaveKey: Uint8Array; }; indexes: { workspaceId: string;
-
@@ -67,10 +68,12 @@ hasWorked: boolean;timeOffs: ( | { type: "halved_paid_leave"; providedAtPacked?: number; } | { type: "hourly_paid_leave"; hours: number; providedAtPacked?: number; } )[]; }
-
@@ -79,6 +82,7 @@ type: "day_off";} | { type: "paid_leave"; providedAtPacked?: number; } | { type: "legal_leave";
-
@@ -91,6 +95,26 @@ };}; indexes: { "workspaceId/workerId/datePacked": [string, string, number]; }; }; paidLeaveProvision: { key: string; value: { id: string; workerId: string; workspaceId: string; providedAtPacked: number; expiresAtPacked: number; amountDays: number; remainingDays: number; note?: string; createdAt: number; updatedAt: number; }; indexes: { "workspaceId,workerId": [string, string]; "workspaceId,workerId,providedAtPacked": [string, string, number]; }; }; }
-
-
-
@@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com>// SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_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";
-
@@ -15,14 +16,56 @@ import { type YamoriDB } from "../../../types";import * as leave from "./leave"; export function write( export class NoPaidLeaveAvailableError extends Error { constructor(public readonly at: proto.MessageInitShape<typeof DateSchema>) { super(`No paid leave is available at ${at.year}-${at.month}-${at.day}`); } } export class NoPaidLeaveProvidedAtError extends Error { constructor(public readonly providedAt: proto.MessageInitShape<typeof DateSchema>) { super( `No paid leave was provided at ${providedAt.year}-${providedAt.month}-${providedAt.day}`, ); } } export class InsufficientPaidLeaveRemainingsError extends Error { constructor( public readonly remainingDays: number, public readonly requestedDays: number, ) { super( `Requested ${requestedDays} paid leaves, but only ${remainingDays} days remaining.`, ); } } export interface WriteInput { getPaidLeaveProvisions(): Promise<YamoriDB["paidLeaveProvision"]["value"][]>; consumePaidLeave( provision: YamoriDB["paidLeaveProvision"]["value"], amountDays: number, ): Promise<void>; mask?: proto.MessageInitShape<typeof WorkRecordBatchWriteMaskSchema>; } export async function write( base: YamoriDB["workRecords"]["value"], message: proto.MessageShape<typeof WorkRecordBatchWriteInputSchema>, { fields = WorkRecordBatchWriteInputSchema.fields.map((field) => field.number), }: proto.MessageInitShape<typeof WorkRecordBatchWriteMaskSchema> = {}, ): YamoriDB["workRecords"]["value"] { getPaidLeaveProvisions, consumePaidLeave, mask: { fields = WorkRecordBatchWriteInputSchema.fields.map((field) => field.number), } = {}, }: WriteInput, ): Promise<YamoriDB["workRecords"]["value"]> { const value = { ...base }; let paidLeaveProvisions: YamoriDB["paidLeaveProvision"]["value"][] | null = null; for (const field of fields) { switch (field) {
-
@@ -40,24 +83,67 @@ }break; case WorkRecordBatchWriteInputSchema.field.workingDay.number: if (message.record.case === "workingDay") { if (!paidLeaveProvisions && message.record.value.timeOffs.length > 0) { paidLeaveProvisions = await getPaidLeaveProvisions(); } value.record = { 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}`); } }), timeOffs: await Promise.all( message.record.value.timeOffs.map< Promise< Extract< YamoriDB["workRecords"]["value"]["record"], { type: "working_day" } >["timeOffs"][number] > >(async (timeOff) => { const providedAt = timeOff.kind.value?.providedAt && packDate(timeOff.kind.value.providedAt); const provision = providedAt ? paidLeaveProvisions?.find( (provision) => provision.providedAtPacked === providedAt, ) : paidLeaveProvisions ?.filter( (provision) => provision.providedAtPacked <= value.datePacked && provision.expiresAtPacked > value.datePacked, ) .sort((a, b) => a.expiresAtPacked - b.expiresAtPacked)[0]; if (!provision) { if (timeOff.kind.value?.providedAt) { throw new NoPaidLeaveProvidedAtError(timeOff.kind.value.providedAt); } throw new NoPaidLeaveAvailableError(unpackDate(value.datePacked)); } switch (timeOff.kind.case) { case "hourlyPaidLeave": // TODO: 時間単位年休に対応 await consumePaidLeave(provision, 1); return { type: "hourly_paid_leave", hours: timeOff.kind.value.hours, providedAtPacked: provision.providedAtPacked, }; case "halvedPaidLeave": await consumePaidLeave(provision, 0.5); return { type: "halved_paid_leave", providedAtPacked: provision.providedAtPacked, }; default: throw new Error(`Unsupported time_off type: ${timeOff.kind.case}`); } }), ), }; } else if (value.record?.type === "working_day") { value.record = undefined;
-
@@ -65,8 +151,37 @@ }break; case WorkRecordBatchWriteInputSchema.field.paidLeave.number: if (message.record.case === "paidLeave") { if (!paidLeaveProvisions) { paidLeaveProvisions = await getPaidLeaveProvisions(); } const providedAt = message.record.value.providedAt && packDate(message.record.value.providedAt); const provision = providedAt ? paidLeaveProvisions?.find( (provision) => provision.providedAtPacked === providedAt, ) : paidLeaveProvisions ?.filter( (provision) => provision.providedAtPacked <= value.datePacked && provision.expiresAtPacked > value.datePacked, ) .sort((a, b) => a.expiresAtPacked - b.expiresAtPacked)[0]; if (!provision) { if (message.record.value.providedAt) { throw new NoPaidLeaveProvidedAtError(message.record.value.providedAt); } throw new NoPaidLeaveAvailableError(unpackDate(value.datePacked)); } await consumePaidLeave(provision, 1); value.record = { type: "paid_leave", providedAtPacked: provision.providedAtPacked, }; } else if (value.record?.type === "paid_leave") { value.record = undefined;
-
@@ -170,7 +285,12 @@ case WorkRecordSchema.field.paidLeave.number:if (entry.record?.type === "paid_leave") { init.record = { case: "paidLeave", value: {}, value: { providedAt: typeof entry.record.providedAtPacked === "number" ? unpackDate(entry.record.providedAtPacked) : undefined, }, }; } break;
-
@@ -189,6 +309,10 @@ kind: {case: "hourlyPaidLeave", value: { hours: timeOff.hours, providedAt: typeof timeOff.providedAtPacked === "number" ? unpackDate(timeOff.providedAtPacked) : undefined, }, }, };
-
@@ -197,9 +321,10 @@ return {kind: { case: "halvedPaidLeave", value: { // TS の型推論がおもちゃなのでこれがないと意味不明な型を // 当てはめようとしてエラーになる。 providedAt: undefined, providedAt: typeof timeOff.providedAtPacked === "number" ? unpackDate(timeOff.providedAtPacked) : undefined, }, }, };
-
-
-
@@ -0,0 +1,188 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { timestampFromMs } from "@bufbuild/protobuf/wkt"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { PaidLeaveProvisionInputSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_pb.js"; import { PaidLeaveProvisionInputMaskSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_mask_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { PaidLeaveProvisionReadMaskSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_read_mask_pb.js"; import { PaidLeaveProvisionFilterSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_filter_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { packDate, unpackDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; // TODO: 閏年の 2/29 の 2 年後がどうなるのか確認する function defaultExpiresAt( provided: proto.MessageShape<typeof DateSchema>, ): proto.MessageShape<typeof DateSchema> { const d = new Date(provided.year + 2, provided.month - 1, provided.day); return proto.create(DateSchema, { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), }); } export function init( workspaceID: string, workerID: string, providedAt: proto.MessageShape<typeof DateSchema>, ): YamoriDB["paidLeaveProvision"]["value"] { return { id: crypto.randomUUID(), workerId: workerID, workspaceId: workspaceID, providedAtPacked: packDate(providedAt), expiresAtPacked: packDate(defaultExpiresAt(providedAt)), amountDays: 0, remainingDays: 0, createdAt: Date.now(), updatedAt: Date.now(), }; } export function write( base: YamoriDB["paidLeaveProvision"]["value"], message: proto.MessageShape<typeof PaidLeaveProvisionInputSchema>, { fields = PaidLeaveProvisionInputSchema.fields.map((field) => field.number), }: proto.MessageInitShape<typeof PaidLeaveProvisionInputMaskSchema> = {}, ): YamoriDB["paidLeaveProvision"]["value"] { const value = { ...base }; for (const field of fields) { switch (field) { case PaidLeaveProvisionInputSchema.field.note.number: value.note = message.note; break; case PaidLeaveProvisionInputSchema.field.expiresAt.number: if (message.expiresAt) { value.expiresAtPacked = packDate(message.expiresAt); } else if (message.providedAt) { value.expiresAtPacked = packDate(defaultExpiresAt(message.providedAt)); } break; case PaidLeaveProvisionInputSchema.field.amountDays.number: value.amountDays = message.amountDays; value.remainingDays = message.amountDays; break; } } return value; } export interface GetOneByProvidedAtInput { workspaceID: string; workerID: string; providedAt: proto.MessageInitShape<typeof DateSchema>; } export function getOneByprovidedAt( { workerID, workspaceID, providedAt }: GetOneByProvidedAtInput, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvision", "readonly" | "readwrite" >, ): Promise<YamoriDB["paidLeaveProvision"]["value"] | undefined> { return store .index("workspaceId,workerId,providedAtPacked") .get([workspaceID, workerID, packDate(providedAt)]); } export interface GetAllForWorkerInput { workspaceID: string; workerID: string; filter?: proto.MessageInitShape<typeof PaidLeaveProvisionFilterSchema>; } export async function getAllForWorker( { workerID, workspaceID, filter = {} }: GetAllForWorkerInput, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvision", "readonly" | "readwrite" >, ): Promise<YamoriDB["paidLeaveProvision"]["value"][]> { const providedAtSince = filter.providedAtSince ? packDate(filter.providedAtSince) : -Infinity; const providedAtUntil = filter.providedAtUntil ? packDate(filter.providedAtUntil) : +Infinity; const expiresAtSince = filter.expiresAtSince ? packDate(filter.expiresAtSince) : -Infinity; const expiresAtUntil = filter.expiresAtUntil ? packDate(filter.expiresAtUntil) : +Infinity; const entries = await store .index("workspaceId,workerId") .getAll([workspaceID, workerID]); return entries.filter((entry) => { return ( entry.providedAtPacked >= providedAtSince && entry.providedAtPacked <= providedAtUntil && entry.expiresAtPacked >= expiresAtSince && entry.expiresAtPacked <= expiresAtUntil ); }); } export interface BuildInput { mask?: proto.MessageInitShape<typeof PaidLeaveProvisionReadMaskSchema>; } export async function build( entry: YamoriDB["paidLeaveProvision"]["value"], { mask: { fields = PaidLeaveProvisionSchema.fields.map((field) => field.number) } = {}, }: BuildInput, ): Promise<proto.MessageShape<typeof PaidLeaveProvisionSchema>> { const init: proto.MessageInitShape<typeof PaidLeaveProvisionSchema> = {}; for (const field of fields) { switch (field) { case PaidLeaveProvisionSchema.field.providedAt.number: init.providedAt = unpackDate(entry.providedAtPacked); break; case PaidLeaveProvisionSchema.field.expiresAt.number: init.expiresAt = unpackDate(entry.expiresAtPacked); break; case PaidLeaveProvisionSchema.field.amountDays.number: init.amountDays = entry.amountDays; break; case PaidLeaveProvisionSchema.field.note.number: init.note = entry.note; break; case PaidLeaveProvisionSchema.field.createdAt.number: init.createdAt = timestampFromMs(entry.createdAt); break; case PaidLeaveProvisionSchema.field.updatedAt.number: init.updatedAt = timestampFromMs(entry.updatedAt); break; case PaidLeaveProvisionSchema.field.remainingDays.number: init.remainingDays = Math.max(0, Math.floor(entry.remainingDays)); break; case PaidLeaveProvisionSchema.field.isHalvedDayRemaining.number: init.isHalvedDayRemaining = Math.round(entry.remainingDays % 1) === 1; break; } } return proto.create(PaidLeaveProvisionSchema, init); }
-
-
-
@@ -2,12 +2,15 @@ // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com>// SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type WorkerReadMaskSchema } from "@yamori/proto/yamori/worker/v1/worker_read_mask_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { type YamoriDB } from "../../../types"; import * as workRecord from "../../work_record/v1/work_record"; import * as paidLeaveProvision from "./paid_leave_provision"; export async function getAllForWorkspace( workspaceID: string,
-
@@ -26,7 +29,11 @@export interface BuildInput { workspace: YamoriDB["workspaces"]["value"]; getWorkRecords(): Promise<YamoriDB["workRecords"]["value"][]>; getWorkRecords( since?: proto.MessageInitShape<typeof DateSchema>, until?: proto.MessageInitShape<typeof DateSchema>, ): Promise<YamoriDB["workRecords"]["value"][]>; getPaidLeaveProvisions(): Promise<YamoriDB["paidLeaveProvision"]["value"][]>; mask?: proto.MessageInitShape<typeof WorkerReadMaskSchema>; }
-
@@ -36,9 +43,11 @@ entry: YamoriDB["workers"]["value"],{ workspace, getWorkRecords, getPaidLeaveProvisions, mask: { fields = WorkerSchema.fields.map((field) => field.number), workRecordsMask, paidLeaveProvisionsMask, } = {}, }: BuildInput, ): Promise<proto.MessageShape<typeof WorkerSchema>> {
-
@@ -64,6 +73,22 @@ workspace,mask: workRecordsMask, }), ); break; } case WorkerSchema.field.providePaidLeaveKey.number: init.providePaidLeaveKey = { key: entry.providePaidLeaveKey }; break; case WorkerSchema.field.paidLeaveProvisions.number: { const provisions = await getPaidLeaveProvisions(); init.paidLeaveProvisions = await Promise.all( provisions.map((p) => paidLeaveProvision.build(p, { mask: paidLeaveProvisionsMask, }), ), ); break; } }
-
-
-
@@ -14,6 +14,9 @@ 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 { ProvidePaidLeaveRequestSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_request_pb.js"; import { ProvidePaidLeaveResponseSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_response_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js";
-
@@ -81,3 +84,37 @@ }return resp.result.value; } export async function providePaidProvision( ctx: Context, payload: MessageInitShape<typeof ProvidePaidLeaveRequestSchema>, ): Promise<MessageShape<typeof PaidLeaveProvisionSchema>> { const resp = fromBinary( ProvidePaidLeaveResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "ProvidePaidLeave", data: toBinary( ProvidePaidLeaveRequestSchema, create(ProvidePaidLeaveRequestSchema, payload), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for paid leave provision, got ${JSON.stringify(resp.result.case)}`, ); } if (!resp.result.value.paidLeaveProvision) { expect.unreachable( `Expected ProvidePaidLeave method to return a written provision, got empty value`, ); } return resp.result.value.paidLeaveProvision; }
-
-
-
@@ -125,6 +125,7 @@ displayName: req.displayName,createdAt: Date.now(), updatedAt: Date.now(), writeWorkRecordKey: createRandomBytes(16), providePaidLeaveKey: createRandomBytes(16), }); const added = await store.get(addedKey);
-
@@ -142,6 +143,10 @@ workspaceID: ws.id,}, tx.objectStore("workRecords"), ); }, async getPaidLeaveProvisions() { // 作成時に付与は行っていない。 return []; }, mask: req.readMask, });
-
-
-
@@ -14,6 +14,7 @@import { type Context } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude<
-
@@ -64,7 +65,10 @@let tx; let ws; try { tx = ctx.db.transaction(["workspaces", "workers", "workRecords"], "readonly"); tx = ctx.db.transaction( ["workspaces", "workers", "workRecords", "paidLeaveProvision"], "readonly", ); ws = await tx.objectStore("workspaces").get(workspaceId.value); if (!ws) {
-
@@ -104,14 +108,30 @@ }const value = await worker.build(found, { workspace: ws, getWorkRecords() { getWorkRecords(since, until) { return workRecord.getAllForWorker( { workspaceID: workspaceId.value, workerID: workerId.value, filter: req.workRecordFilter, filter: since && until ? { since, until, } : req.workRecordFilter, }, tx.objectStore("workRecords"), ); }, getPaidLeaveProvisions() { return paidLeaveProvision.getAllForWorker( { workspaceID: workspaceId.value, workerID: workerId.value, filter: req.paidLeaveProvisionFilter, }, tx.objectStore("paidLeaveProvision"), ); }, mask: req.readMask,
-
-
-
@@ -11,6 +11,7 @@import { type Context, type YamoriDB } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; import * as paidLeaveProvision from "../paid_leave_provision"; export function respond( result: Exclude<
-
@@ -50,7 +51,10 @@let tx; let workspace: YamoriDB["workspaces"]["value"] | undefined; try { tx = ctx.db.transaction(["workspaces", "workers", "workRecords"], "readonly"); tx = ctx.db.transaction( ["workspaces", "workers", "workRecords", "paidLeaveProvision"], "readonly", ); workspace = await tx.objectStore("workspaces").get(req.workspaceId.value);
-
@@ -87,14 +91,24 @@ const workers = await Promise.all(entries.map(async (entry) => worker.build(entry, { workspace, getWorkRecords() { getWorkRecords(since, until) { return workRecord.getAllForWorker( { workerID: entry.id, workspaceID: workspace.id, filter: req.workRecordFilter, filter: since && until ? { since, until } : req.workRecordFilter, }, tx.objectStore("workRecords"), ); }, getPaidLeaveProvisions() { return paidLeaveProvision.getAllForWorker( { workspaceID: workspace.id, workerID: entry.id, filter: req.paidLeaveProvisionFilter, }, tx.objectStore("paidLeaveProvision"), ); }, mask: req.readMask,
-
-
-
@@ -0,0 +1,366 @@// 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 * as proto from "@bufbuild/protobuf"; import { ProvidePaidLeaveRequestSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_request_pb.js"; import { ProvidePaidLeaveResponseSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_response_pb.js"; import { PaidLeaveProvisionInputSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_pb.js"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { createWorkspace } from "../../../workspace/v1/workspace_service/_test_utils"; import { createWorker, getWorker } from "./_test_utils"; import { workerService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function providePaidLeave( ctx: Context, request: proto.MessageInitShape<typeof ProvidePaidLeaveRequestSchema>, ): Promise<proto.MessageShape<typeof ProvidePaidLeaveResponseSchema>> { return proto.fromBinary( ProvidePaidLeaveResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "ProvidePaidLeave", data: proto.toBinary( ProvidePaidLeaveRequestSchema, proto.create(ProvidePaidLeaveRequestSchema, request), ), }, ctx, ), ); } for (const field of [ "workspaceId", "workerId", "providePaidLeaveKey", "paidLeave", ] satisfies (keyof (typeof ProvidePaidLeaveRequestSchema)["field"])[]) { const fieldName = ProvidePaidLeaveRequestSchema.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 providePaidLeave(ctx, { workspaceId: field === "workspaceId" ? undefined : workspace.id, workerId: field === "workerId" ? undefined : worker.id, providePaidLeaveKey: field === "providePaidLeaveKey" ? undefined : worker.providePaidLeaveKey, paidLeave: field === "paidLeave" ? undefined : { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 1, }, }); 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 missing_field if provided_at is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { amountDays: 1, }, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("paid_leave.provided_at"); }); test("Should return capability_error if key does not match", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const alice = await createWorker(ctx, workspace); const bob = await createWorker(ctx, workspace); const resp = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: alice.id, providePaidLeaveKey: bob.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 1, }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe( ProvidePaidLeaveRequestSchema.field.providePaidLeaveKey.name, ); }); for (const typeName of [ "yamori.workspace.v1.Workspace", "yamori.worker.v1.Worker", ] as const) { test(`Should return not_found for ${typeName}`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace(ctx); const alice = await createWorker(ctx, foo); const bar = await createWorkspace(ctx); const bob = await createWorker(ctx, bar); const resp = await providePaidLeave(ctx, { workspaceId: typeName === "yamori.workspace.v1.Workspace" ? { value: foo.id?.value + "-000", } : foo.id, workerId: typeName === "yamori.worker.v1.Worker" ? bob.id : alice.id, providePaidLeaveKey: alice.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 1, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe(typeName); }); } test("Should provide paid leave with default expiration date", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 10, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } const provision = resp.result.value.paidLeaveProvision; if (!provision) { expect.unreachable("Got empty paid_leave_provision from ProvidePaidLeave"); } expect(provision.providedAt).toMatchObject({ year: 2020, month: 1, day: 1, }); expect(provision.expiresAt).toMatchObject({ year: 2022, month: 1, day: 1, }); expect(provision.amountDays).toBe(10); expect(provision.remainingDays).toBe(10); expect(provision.isHalvedDayRemaining).toBe(false); const workerAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, }); expect(workerAfter.paidLeaveProvisions).toContainEqual(provision); }); test("Should delete record when amount is 0", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp1 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 10, }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } if (!resp1.result.value.paidLeaveProvision) { expect.unreachable("Got empty paid_leave_provision from ProvidePaidLeave"); } const resp2 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 0, }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } expect(resp2.result.value.paidLeaveProvision).toBeEmpty(); const workerAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, }); expect(workerAfter.paidLeaveProvisions).toHaveLength(0); }); test("Should overwrite", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp1 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 10, }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } const resp2 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, expiresAt: { year: 2030, month: 5, day: 5, }, note: "Foo", }, writeMask: { fields: [ PaidLeaveProvisionInputSchema.field.note.number, PaidLeaveProvisionInputSchema.field.expiresAt.number, ], }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } const workerAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, }); expect(workerAfter.paidLeaveProvisions).toEqual([ expect.objectContaining({ providedAt: expect.objectContaining({ year: 2020, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2030, month: 5, day: 5, }), amountDays: 10, note: "Foo", }), ]); });
-
-
-
@@ -0,0 +1,238 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { ProvidePaidLeaveRequestSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_request_pb.js"; import { ProvidePaidLeaveResponseSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_response_pb.js"; import { PaidLeaveProvisionInputSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude< NonNullable<proto.MessageInitShape<typeof ProvidePaidLeaveResponseSchema>["result"]>, { case: undefined } >, ): Uint8Array { return proto.toBinary( ProvidePaidLeaveResponseSchema, proto.create(ProvidePaidLeaveResponseSchema, { result }), ); } export async function providePaidLeave( data: Uint8Array, ctx: Context, ): Promise<Uint8Array> { let req; try { req = proto.fromBinary(ProvidePaidLeaveRequestSchema, 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}`, }, }); } const { workspaceId, workerId, providePaidLeaveKey, paidLeave } = req; if (!workspaceId) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.workspaceId.name, }, }); } if (!workerId) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.workerId.name, }, }); } if (!providePaidLeaveKey) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.providePaidLeaveKey.name, }, }); } if (!paidLeave) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.paidLeave.name, }, }); } if (!paidLeave.providedAt) { return respond({ case: "missingField", value: { path: [ ProvidePaidLeaveRequestSchema.field.paidLeave.name, PaidLeaveProvisionInputSchema.field.providedAt.name, ].join("."), }, }); } let tx; try { tx = ctx.db.transaction( ["workspaces", "workers", "workRecords", "paidLeaveProvision"], "readwrite", ); } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to start a transaction" : `Exception thrown during transaction start call: ${error}`, }, }); } try { const found = await tx.objectStore("workspaces").get(workspaceId.value); if (!found) { return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } } catch (error) { tx.abort(); 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 found = await tx.objectStore("workers").get(workerId.value); if (!found || found.workspaceId !== workspaceId.value) { return respond({ case: "notFound", value: { typeName: "yamori.worker.v1.Worker", }, }); } if (!isSameBytes(found.providePaidLeaveKey, providePaidLeaveKey.key)) { return respond({ case: "capabilityError", value: { path: ProvidePaidLeaveRequestSchema.field.providePaidLeaveKey.name, }, }); } } catch (error) { tx.abort(); 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 store = tx.objectStore("paidLeaveProvision"); const existing = await paidLeaveProvision.getOneByprovidedAt( { workerID: workerId.value, workspaceID: workspaceId.value, providedAt: paidLeave.providedAt, }, store, ); const payload = paidLeaveProvision.write( existing ?? paidLeaveProvision.init(workspaceId.value, workerId.value, paidLeave.providedAt), paidLeave, req.writeMask, ); if (payload.amountDays === 0) { if (existing) { await store.delete(existing.id); } await tx.done; return respond({ case: "ok", value: {}, }); } const addedKey = await store.put(payload); const added = await store.get(addedKey); if (!added) { throw new Error("Failed to retrieve added entry"); } const value = await paidLeaveProvision.build(added, { mask: req.readMask, }); await tx.done; return respond({ case: "ok", value: { paidLeaveProvision: value, }, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during writing paid leave provision record" : `Exception thrown during providing paid leave: ${error}`, }, }); } }
-
-
-
@@ -6,6 +6,7 @@import { create } from "./create"; import { get } from "./get"; import { list } from "./list"; import { providePaidLeave } from "./provide_paid_leave"; import { writeWorkRecord } from "./write_work_record"; export async function workerService(
-
@@ -21,6 +22,8 @@ case "Get":return get(request.data, ctx); case "WriteWorkRecord": return writeWorkRecord(request.data, ctx); case "ProvidePaidLeave": return providePaidLeave(request.data, ctx); default: throw new Error(`Unknown method "${request.method}" for ${request.service}`); }
-
-
-
@@ -25,7 +25,7 @@ import {createWorkspace, createLeaveDefinition, } from "../../../workspace/v1/workspace_service/_test_utils"; import { createWorker, getWorker } from "./_test_utils"; import { createWorker, getWorker, providePaidProvision } from "./_test_utils"; import { workerService } from "./service"; beforeEach(() => {
-
@@ -249,6 +249,254 @@ expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`);} expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test("Should prevent setting a paid leave if none is available", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2022, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "paidLeave", value: {}, }, }, }); 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.PaidLeaveProvision"); }); test("Should prevent setting a paid leave if none has corresponding provided_at", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2023, month: 1, day: 2, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "paidLeave", value: { providedAt: { year: 2023, month: 1, day: 1, }, }, }, }, }); 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.PaidLeaveProvision"); }); test("Should prevent setting paid leaves result in exceeding amount", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }, ], record: { case: "paidLeave", value: {}, }, }, }); 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.PaidLeaveProvision"); }); test("Should allow paid leaves", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 10, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }, ], record: { case: "paidLeave", value: {}, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2026, month: 1, day: 1, }, }, readMask: { fields: [ WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "paidLeave", value: expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), }), }), { date: expect.objectContaining({ year: 2025, month: 1, day: 1, }), record: expect.objectContaining({ case: "paidLeave", value: expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), }), }, ], paidLeaveProvisions: [ expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2026, month: 1, day: 1, }), amountDays: 10, remainingDays: 8, }), ], }); }); test("Should write a note", async () => {
-
-
-
@@ -15,6 +15,7 @@import { isSameBytes, packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude<
-
@@ -184,19 +185,28 @@ },}); } let tx; try { const tx = db.transaction("workRecords", "readwrite"); tx = db.transaction(["workRecords", "paidLeaveProvision"], "readwrite"); // idb は abort() を呼び出すことが考慮されていないため、エラーハンドラが // 一つもない状態で tx.abort() をすると done が reject されてしまう。 // Unhandled Rejection を防ぐためにダミーの catch が必要。 tx.done.catch(() => {}); const store = tx.objectStore("workRecords"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); const added: YamoriDB["workRecords"]["value"][] = []; for (const date of req.workRecord.dates) { const packed = packDate(date); const existing = await tx.store const existing = await store .index("workspaceId/workerId/datePacked") .get([req.workspaceId.value, req.workerId.value, packed]); const payload = workRecord.write( const payload = await workRecord.write( existing || { recordId: crypto.randomUUID(), workerId: req.workerId.value,
-
@@ -204,10 +214,34 @@ workspaceId: req.workspaceId.value,datePacked: packed, }, req.workRecord, req.writeMask, { getPaidLeaveProvisions() { return paidLeaveProvision.getAllForWorker( { workerID: req.workerId!.value, workspaceID: req.workspaceId!.value, }, paidLeaveProvisions, ); }, async consumePaidLeave(provision, amountDays) { if (provision.remainingDays < amountDays) { throw new workRecord.InsufficientPaidLeaveRemainingsError( provision.remainingDays, amountDays, ); } await paidLeaveProvisions.put({ ...provision, remainingDays: Math.max(0, provision.remainingDays - amountDays), }); }, mask: req.writeMask, }, ); tx.store.put(payload); store.put(payload); added.push(payload); }
-
@@ -227,6 +261,22 @@ workRecords: value,}, }); } catch (error) { tx?.abort(); if ( error instanceof workRecord.NoPaidLeaveAvailableError || error instanceof workRecord.NoPaidLeaveProvidedAtError || // TODO: 適切ではないのでエラーを追加する error instanceof workRecord.InsufficientPaidLeaveRemainingsError ) { return respond({ case: "notFound", value: { typeName: "yamori.worker.v1.PaidLeaveProvision", }, }); } return respond({ case: "systemError", value: {
-