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/domain/exceptions/DomainExceptions.ts b/src/domain/exceptions/DomainExceptions.ts index 9e2b8c4..0168334 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..f915e76 --- /dev/null +++ b/src/domain/shared/StudentId.ts @@ -0,0 +1,34 @@ +import { ValueObject } from "#domain/base/ValueObject"; +import { InvalidStudentIdException } from "#domain/exceptions"; + +/** + * 学籍番号 + * + * 2つのフォーマットに対応する: + * - 数字8桁(例: 70312031) + * - 数字3桁 + アルファベット1文字 + 数字4桁(例: 725A1061) + * + * 2025年度入学者から英字を含む形式に変更された。 + * アルファベットは大文字に正規化して保持する。 + */ +export class StudentId extends ValueObject { + 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); + } + + static fromString(value: string): StudentId { + return new StudentId(value.trim().toUpperCase()); + } + + protected validate(): void { + const isValid = + StudentId.NUMERIC_ONLY.test(this.value) || + StudentId.ALPHANUMERIC.test(this.value); + if (!isValid) { + throw 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/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, 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); + }); + }); +});