From 1caa387093cd34f1a3f391d60163d02232b48dda Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 15:15:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20SharedKernel=20StudentId=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=A8=E3=83=A6=E3=83=8B=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学籍番号の値オブジェクトを追加。旧形式(8桁数字)と新形式(3桁数字+英字+4桁数字)に対応し、 小文字英字の大文字正規化・前後空白トリムを行うファクトリメソッドを提供する。 Co-Authored-By: Claude Opus 4.6 --- src/domain/exceptions/DomainExceptions.ts | 9 +++ src/domain/index.ts | 1 + src/domain/shared/StudentId.ts | 31 +++++++++ src/domain/shared/index.ts | 1 + tests/domain/shared/StudentId.test.ts | 77 +++++++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 src/domain/shared/StudentId.ts create mode 100644 src/domain/shared/index.ts create mode 100644 tests/domain/shared/StudentId.test.ts diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 9e2b8c4..65e41a3 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -104,3 +104,12 @@ export class ExhibitHasMemberException extends DomainException { this.name = "ExhibitHasMemberException"; } } + +export class InvalidStudentIdException extends DomainException { + constructor(studentId: string) { + super( + `無効な学籍番号です: ${studentId} (8桁数字 または 3桁数字+1文字+4桁数字)`, + ); + this.name = "InvalidStudentIdException"; + } +} diff --git a/src/domain/index.ts b/src/domain/index.ts index 569c894..9fbfe58 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -1,3 +1,4 @@ export * from "./base"; export * from "./exceptions"; export * from "./aggregates"; +export * from "./shared"; diff --git a/src/domain/shared/StudentId.ts b/src/domain/shared/StudentId.ts new file mode 100644 index 0000000..000d93c --- /dev/null +++ b/src/domain/shared/StudentId.ts @@ -0,0 +1,31 @@ +import { ValueObject } from "#domain/base/ValueObject"; +import { InvalidStudentIdException } from "#domain/exceptions"; + +/** + * 学籍番号 + * + * 2つのフォーマットに対応する: + * - 旧形式: 8桁の数字(例: 70312031) + * - 新形式: 数字3桁 + アルファベット1文字 + 数字4桁(例: 725A1061) + * + * アルファベットは大文字に正規化して保持する。 + */ +export class StudentId extends ValueObject { + private static readonly OLD_FORMAT = /^[0-9]{8}$/; + private static readonly NEW_FORMAT = /^[0-9]{3}[A-Z][0-9]{4}$/; + + private constructor(value: string) { + super(value); + } + + static fromString(value: string): StudentId { + return new StudentId(value.trim().toUpperCase()); + } + + protected validate(): void { + const isValid = + StudentId.OLD_FORMAT.test(this.value) || + StudentId.NEW_FORMAT.test(this.value); + this.throwIfInvalid(isValid, new InvalidStudentIdException(this.value)); + } +} diff --git a/src/domain/shared/index.ts b/src/domain/shared/index.ts new file mode 100644 index 0000000..e3bd313 --- /dev/null +++ b/src/domain/shared/index.ts @@ -0,0 +1 @@ +export * from "./StudentId"; diff --git a/tests/domain/shared/StudentId.test.ts b/tests/domain/shared/StudentId.test.ts new file mode 100644 index 0000000..afaa091 --- /dev/null +++ b/tests/domain/shared/StudentId.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { InvalidStudentIdException } from "#domain/exceptions"; +import { StudentId } from "#domain/shared/StudentId"; + +describe("StudentId", () => { + describe("fromString", () => { + it("旧形式(8桁数字)の学籍番号を生成できる", () => { + const id = StudentId.fromString("70312031"); + expect(id.getValue()).toBe("70312031"); + }); + + it("新形式(3桁数字+英字+4桁数字)の学籍番号を生成できる", () => { + const id = StudentId.fromString("725A1061"); + expect(id.getValue()).toBe("725A1061"); + }); + + it("小文字の英字を大文字に正規化する", () => { + const id = StudentId.fromString("725a1061"); + expect(id.getValue()).toBe("725A1061"); + }); + + it("前後の空白をトリムする", () => { + const id = StudentId.fromString(" 70312031 "); + expect(id.getValue()).toBe("70312031"); + }); + }); + + describe("バリデーション", () => { + it("7桁の数字は無効", () => { + expect(() => StudentId.fromString("7031203")).toThrow( + InvalidStudentIdException, + ); + }); + + it("9桁の数字は無効", () => { + expect(() => StudentId.fromString("703120310")).toThrow( + InvalidStudentIdException, + ); + }); + + it("空文字は無効", () => { + expect(() => StudentId.fromString("")).toThrow(InvalidStudentIdException); + }); + + it("英字のみは無効", () => { + expect(() => StudentId.fromString("ABCDEFGH")).toThrow( + InvalidStudentIdException, + ); + }); + + it("新形式で英字が2文字あると無効", () => { + expect(() => StudentId.fromString("72AB1061")).toThrow( + InvalidStudentIdException, + ); + }); + + it("新形式で英字の位置が異なると無効", () => { + expect(() => StudentId.fromString("7A251061")).toThrow( + InvalidStudentIdException, + ); + }); + }); + + describe("equals", () => { + it("同じ学籍番号は等しい", () => { + const a = StudentId.fromString("725A1061"); + const b = StudentId.fromString("725a1061"); + expect(a.equals(b)).toBe(true); + }); + + it("異なる学籍番号は等しくない", () => { + const a = StudentId.fromString("725A1061"); + const b = StudentId.fromString("70312031"); + expect(a.equals(b)).toBe(false); + }); + }); +}); From 4da507ea36f29cc7e9210d3f0859351a9c6ca3ec Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 15:19:21 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20StudentId=E3=81=AE=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=BC=E3=83=9E=E3=83=83=E3=83=88=E5=AE=9A=E6=95=B0?= =?UTF-8?q?=E5=90=8D=E3=82=92=E6=A7=8B=E9=80=A0=E7=9A=84=E3=81=AA=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OLD_FORMAT/NEW_FORMAT → NUMERIC_ONLY/ALPHANUMERIC に改名し、 2025年度からの形式変更をコメントに記載。 Co-Authored-By: Claude Opus 4.6 --- src/domain/shared/StudentId.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/domain/shared/StudentId.ts b/src/domain/shared/StudentId.ts index 000d93c..23dbb89 100644 --- a/src/domain/shared/StudentId.ts +++ b/src/domain/shared/StudentId.ts @@ -5,14 +5,15 @@ import { InvalidStudentIdException } from "#domain/exceptions"; * 学籍番号 * * 2つのフォーマットに対応する: - * - 旧形式: 8桁の数字(例: 70312031) - * - 新形式: 数字3桁 + アルファベット1文字 + 数字4桁(例: 725A1061) + * - 数字8桁(例: 70312031) + * - 数字3桁 + アルファベット1文字 + 数字4桁(例: 725A1061) * + * 2025年度入学者から英字を含む形式に変更された。 * アルファベットは大文字に正規化して保持する。 */ export class StudentId extends ValueObject { - private static readonly OLD_FORMAT = /^[0-9]{8}$/; - private static readonly NEW_FORMAT = /^[0-9]{3}[A-Z][0-9]{4}$/; + private static readonly NUMERIC_ONLY = /^[0-9]{8}$/; + private static readonly ALPHANUMERIC = /^[0-9]{3}[A-Z][0-9]{4}$/; private constructor(value: string) { super(value); @@ -24,8 +25,8 @@ export class StudentId extends ValueObject { protected validate(): void { const isValid = - StudentId.OLD_FORMAT.test(this.value) || - StudentId.NEW_FORMAT.test(this.value); + StudentId.NUMERIC_ONLY.test(this.value) || + StudentId.ALPHANUMERIC.test(this.value); this.throwIfInvalid(isValid, new InvalidStudentIdException(this.value)); } } From 74b60448e052f409ff3eb19de3cb4b808f592399 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 15:32:24 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20Member=E3=81=AEstudentId=E3=82=92St?= =?UTF-8?q?udentId=E5=9E=8B=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member集約およびユースケース・リポジトリでstring型だったstudentIdを StudentId値オブジェクトに置き換え、システム境界でバリデーションを保証する。 BREAKING CHANGE: Member constructor, setStudentId, getStudentIdの型がstringからStudentIdに変更 Co-Authored-By: Claude Opus 4.6 --- src/application/usecase/member/RegisterMember.ts | 3 ++- src/application/usecase/member/UpdateMember.ts | 3 ++- src/domain/aggregates/member/Member.ts | 7 ++++--- src/infrastructure/drizzle/DrizzleMemberRepository.ts | 3 ++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/application/usecase/member/RegisterMember.ts b/src/application/usecase/member/RegisterMember.ts index 3af2b45..fb871db 100644 --- a/src/application/usecase/member/RegisterMember.ts +++ b/src/application/usecase/member/RegisterMember.ts @@ -7,6 +7,7 @@ import { Email, Member, type MemberRepository, + StudentId, UniversityEmail, } from "#domain"; @@ -48,7 +49,7 @@ export class RegisterMemberUseCase extends IUseCase< const member = new Member( uuid(), input.name, - input.studentId, + StudentId.fromString(input.studentId), Department.fromString(input.department), universityEmail, personalEmail, diff --git a/src/application/usecase/member/UpdateMember.ts b/src/application/usecase/member/UpdateMember.ts index 8a009e4..16a2ffb 100644 --- a/src/application/usecase/member/UpdateMember.ts +++ b/src/application/usecase/member/UpdateMember.ts @@ -6,6 +6,7 @@ import { Email, type Member, type MemberRepository, + StudentId, UniversityEmail, } from "#domain"; @@ -42,7 +43,7 @@ export class UpdateMemberUseCase extends IUseCase< member.setName(input.name); } if (input.studentId) { - member.setStudentId(input.studentId); + member.setStudentId(StudentId.fromString(input.studentId)); } if (input.department) { member.setDepartment(Department.fromString(input.department)); diff --git a/src/domain/aggregates/member/Member.ts b/src/domain/aggregates/member/Member.ts index 27329ad..3a749e5 100644 --- a/src/domain/aggregates/member/Member.ts +++ b/src/domain/aggregates/member/Member.ts @@ -2,6 +2,7 @@ import { DiscordAccountAlreadyConnectedException, DiscordAccountNotConnectedException, } from "#domain/exceptions"; +import type { StudentId } from "#domain/shared/StudentId"; import type { Department } from "./Departments"; import type { DiscordAccount } from "./DiscordAccount"; import type { Email } from "./Email"; @@ -13,7 +14,7 @@ export class Member { constructor( public readonly id: string, private name: string, - private studentId: string, + private studentId: StudentId, private department: Department, private email: UniversityEmail, private personalEmail?: Email, @@ -47,7 +48,7 @@ export class Member { this.name = newName; } - setStudentId(newStudentId: string) { + setStudentId(newStudentId: StudentId) { this.studentId = newStudentId; } @@ -99,7 +100,7 @@ export class Member { return { id: this.id, name: this.name, - studentId: this.studentId, + studentId: this.studentId.getValue(), department: this.department, email: this.email, personalEmail: this.personalEmail, diff --git a/src/infrastructure/drizzle/DrizzleMemberRepository.ts b/src/infrastructure/drizzle/DrizzleMemberRepository.ts index bcb440d..be924cd 100644 --- a/src/infrastructure/drizzle/DrizzleMemberRepository.ts +++ b/src/infrastructure/drizzle/DrizzleMemberRepository.ts @@ -5,6 +5,7 @@ import { Email, Member, type MemberRepository, + StudentId, UniversityEmail, } from "#domain"; import { getDb } from "./client"; @@ -27,7 +28,7 @@ export class DrizzleMemberRepository implements MemberRepository { const member = new Member( record.id, record.name, - record.studentId, + StudentId.fromString(record.studentId), Department.fromString(record.department), new UniversityEmail(record.email), record.personalEmail ? new Email(record.personalEmail) : undefined, From b6bbe8e61c9096b3313e191065ca5eb1b532edf8 Mon Sep 17 00:00:00 2001 From: KinjiKawaguchi Date: Thu, 12 Mar 2026 15:59:41 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BE=8B=E5=A4=96=E3=83=A1=E3=83=83?= =?UTF-8?q?=E3=82=BB=E3=83=BC=E3=82=B8=E3=81=AB=E8=8B=B1=E5=A4=A7=E6=96=87?= =?UTF-8?q?=E5=AD=97=E3=82=92=E6=98=8E=E7=A4=BA=E3=81=97=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E8=A6=81=E3=81=AA=E4=BE=8B=E5=A4=96=E3=82=AA=E3=83=96=E3=82=B8?= =?UTF-8?q?=E3=82=A7=E3=82=AF=E3=83=88=E7=94=9F=E6=88=90=E3=82=92=E5=9B=9E?= =?UTF-8?q?=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/domain/exceptions/DomainExceptions.ts | 2 +- src/domain/shared/StudentId.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 65e41a3..0168334 100644 --- a/src/domain/exceptions/DomainExceptions.ts +++ b/src/domain/exceptions/DomainExceptions.ts @@ -108,7 +108,7 @@ export class ExhibitHasMemberException extends DomainException { export class InvalidStudentIdException extends DomainException { constructor(studentId: string) { super( - `無効な学籍番号です: ${studentId} (8桁数字 または 3桁数字+1文字+4桁数字)`, + `無効な学籍番号です: ${studentId} (8桁数字 または 3桁数字+英大文字1文字+4桁数字)`, ); this.name = "InvalidStudentIdException"; } diff --git a/src/domain/shared/StudentId.ts b/src/domain/shared/StudentId.ts index 23dbb89..f915e76 100644 --- a/src/domain/shared/StudentId.ts +++ b/src/domain/shared/StudentId.ts @@ -27,6 +27,8 @@ export class StudentId extends ValueObject { const isValid = StudentId.NUMERIC_ONLY.test(this.value) || StudentId.ALPHANUMERIC.test(this.value); - this.throwIfInvalid(isValid, new InvalidStudentIdException(this.value)); + if (!isValid) { + throw new InvalidStudentIdException(this.value); + } } }