Line data Source code
1 : // Package entity はフリーランサーの住所を表すドメインエンティティです
2 : package entity
3 :
4 : import (
5 : "errors"
6 : "regexp"
7 : "strings"
8 : "time"
9 :
10 : "gorm.io/gorm"
11 :
12 : "resume/internal/shared/jpstring"
13 : )
14 :
15 : // UserAddress はフリーランサーの住所を表すドメインエンティティです
16 : type UserAddress struct {
17 : ID uint64 `gorm:"primaryKey;autoIncrement"`
18 : UserID uint64 `gorm:"not null;index:idx_user_primary,priority:1"` // users.id FK
19 : PurposeID uint64 `gorm:"not null;index:uq_user_purpose_primary,unique,priority:2"` // address_purposes.id FK
20 :
21 : IsPrimary bool `gorm:"not null;default:false;index:idx_user_primary,priority:2"`
22 :
23 : CountryCode string `gorm:"type:char(2);not null;index:idx_country_postal,priority:1"`
24 : AdministrativeArea *string `gorm:"size:128;index:idx_loc,priority:2"`
25 : Locality *string `gorm:"size:128;index:idx_loc,priority:3"`
26 : DependentLocality *string `gorm:"size:128"`
27 : PostalCode *string `gorm:"size:32;index:idx_country_postal,priority:2"`
28 : SortingCode *string `gorm:"size:32"`
29 :
30 : AddressLine1 string `gorm:"size:160;not null"`
31 : AddressLine2 *string `gorm:"size:160"`
32 : AddressLine3 *string `gorm:"size:160"`
33 :
34 : Organization string `gorm:"size:160"`
35 : GivenName string `gorm:"size:80"`
36 : FamilyName string `gorm:"size:80"`
37 :
38 : LanguageCode string `gorm:"size:35"`
39 : Latitude *float64 `gorm:"type:decimal(9,6)"`
40 : Longitude *float64 `gorm:"type:decimal(9,6)"`
41 :
42 : CreatedAt time.Time `gorm:"autoCreateTime"`
43 : UpdatedAt time.Time `gorm:"autoUpdateTime"`
44 : DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
45 :
46 : // 関連
47 : Purpose *AddressPurpose `gorm:"foreignKey:PurposeID;references:ID"`
48 : }
49 :
50 : // TableName は GORM のテーブル名を返します。
51 0 : func (UserAddress) TableName() string {
52 0 : return "user_addresses"
53 0 : }
54 :
55 : // UserAddressParam は UserAddress を生成するための入力値を表します。
56 : // バリデーションは NewUserAddress 内で行われ、無効な場合はエラーを返します。
57 : type UserAddressParam struct {
58 : UserID, PurposeID uint64
59 : IsPrimary bool
60 : CountryCode string
61 : AdministrativeArea, Locality, DependentLocality, PostalCode, SortingCode *string
62 : AddressLine1 string
63 : AddressLine2, AddressLine3 *string
64 : Latitude, Longitude *float64
65 : }
66 :
67 : // NewUserAddress は UserAddress エンティティを生成します。
68 : // 与えられた UserAddressParam が無効な場合は ErrInvalidAddress などを返します。
69 0 : func NewUserAddress(p UserAddressParam) (*UserAddress, error) {
70 0 :
71 0 : if (p.Latitude != nil && p.Longitude == nil) || (p.Latitude == nil && p.Longitude != nil) {
72 0 : return nil, errors.Join(ErrInvalidAddress, errCoordPair)
73 0 : }
74 :
75 0 : if p.Latitude != nil && (*p.Latitude < -90 || *p.Latitude > 90) {
76 0 : return nil, errors.Join(ErrInvalidAddress, errLatRange)
77 0 : }
78 :
79 0 : if p.Longitude != nil && (*p.Longitude < -180 || *p.Longitude > 180) {
80 0 : return nil, errors.Join(ErrInvalidAddress, errLngRange)
81 0 : }
82 :
83 0 : if p.CountryCode == "" {
84 0 : return nil, errors.Join(ErrInvalidAddress, errCountryCode)
85 0 : }
86 :
87 0 : if p.CountryCode == "JP" {
88 0 : if p.AdministrativeArea == nil || *p.AdministrativeArea == "" {
89 0 : return nil, errors.Join(ErrInvalidAddress, errJPPrefRequired)
90 0 : }
91 0 : if !jpstring.IsValidPrefecture(*p.AdministrativeArea) {
92 0 : return nil, errors.Join(ErrInvalidAddress, errJPPrefInvalid)
93 0 : }
94 : }
95 :
96 0 : if p.AddressLine1 == "" {
97 0 : return nil, errors.Join(ErrInvalidAddress, errAddressLine1Required)
98 0 : }
99 :
100 0 : var probs []FieldError
101 0 :
102 0 : // 標準化
103 0 : cc := strings.ToUpper(strings.TrimSpace(p.CountryCode))
104 0 : if len(cc) != 2 {
105 0 : probs = append(probs, FieldError{"countryCode", "len", "2"})
106 0 : }
107 :
108 : // addressLine1 必須(最終防衛)
109 0 : if strings.TrimSpace(p.AddressLine1) == "" {
110 0 : probs = append(probs, FieldError{"addressLine1", "required", ""})
111 0 : }
112 :
113 : // lat/lng のペア & range
114 0 : if (p.Latitude == nil) != (p.Longitude == nil) {
115 0 : probs = append(probs,
116 0 : FieldError{"latitude", "coordpair", "both_or_none"},
117 0 : FieldError{"longitude", "coordpair", "both_or_none"},
118 0 : )
119 0 : } else if p.Latitude != nil && p.Longitude != nil {
120 0 : if *p.Latitude < -90 || *p.Latitude > 90 {
121 0 : probs = append(probs, FieldError{"latitude", "range", "[-90,90]"})
122 0 : }
123 0 : if *p.Longitude < -180 || *p.Longitude > 180 {
124 0 : probs = append(probs, FieldError{"longitude", "range", "[-180,180]"})
125 0 : }
126 : }
127 :
128 : // JP 固有
129 0 : if cc == "JP" {
130 0 : // 都道府県必須 + 妥当性
131 0 : if p.AdministrativeArea == nil || strings.TrimSpace(*p.AdministrativeArea) == "" {
132 0 : probs = append(probs, FieldError{"administrativeArea", "jppref_required", "required_when:countryCode=JP"})
133 0 : } else {
134 0 : name := jpstring.NormalizeSpaces(*p.AdministrativeArea)
135 0 : if !jpstring.IsValidPrefecture(name) {
136 0 : probs = append(probs, FieldError{"administrativeArea", "jpref", ""})
137 0 : } else {
138 0 : // 正規化して反映(スペース整形)
139 0 : p.AdministrativeArea = &name
140 0 : }
141 : }
142 : // 郵便番号:存在すれば書式チェック(全角→半角・ハイフン統一後)
143 0 : if p.PostalCode != nil && strings.TrimSpace(*p.PostalCode) != "" {
144 0 : norm := jpstring.NormalizeDigitsHyphen(*p.PostalCode)
145 0 : if !rePostalJP.MatchString(norm) {
146 0 : probs = append(probs, FieldError{"postalCode", "jppostal", ""})
147 0 : } else {
148 0 : // 保存形を正規化に寄せたいならここで反映
149 0 : p.PostalCode = &norm
150 0 : }
151 : }
152 : }
153 :
154 0 : if len(probs) > 0 {
155 0 : return nil, InvalidAddressError{Problems: probs}
156 0 : }
157 :
158 : // 住所文字列の軽い正規化(任意。UI側ですでにやっていれば薄くてもOK)
159 0 : addr1 := jpstring.NormalizeJP(strings.TrimSpace(p.AddressLine1))
160 0 : var addr2, addr3 *string
161 0 : if p.AddressLine2 != nil {
162 0 : s := jpstring.NormalizeJP(strings.TrimSpace(*p.AddressLine2))
163 0 : addr2 = &s
164 0 : }
165 0 : if p.AddressLine3 != nil {
166 0 : s := jpstring.NormalizeJP(strings.TrimSpace(*p.AddressLine3))
167 0 : addr3 = &s
168 0 : }
169 0 : var locality, depLoc *string
170 0 : if p.Locality != nil {
171 0 : s := jpstring.NormalizeJP(strings.TrimSpace(*p.Locality))
172 0 : locality = &s
173 0 : }
174 0 : if p.DependentLocality != nil {
175 0 : s := jpstring.NormalizeJP(strings.TrimSpace(*p.DependentLocality))
176 0 : depLoc = &s
177 0 : }
178 : // ここで不変条件を最終チェック(例: len(CountryCode)==2、lat/lngのペア等)
179 0 : a := &UserAddress{
180 0 : UserID: p.UserID,
181 0 : PurposeID: p.PurposeID,
182 0 : IsPrimary: p.IsPrimary,
183 0 :
184 0 : CountryCode: cc, // ← cc を使う
185 0 : AdministrativeArea: p.AdministrativeArea,
186 0 : Locality: locality, // ← 正規化版
187 0 : DependentLocality: depLoc, // ← 正規化版
188 0 : PostalCode: p.PostalCode,
189 0 : SortingCode: p.SortingCode,
190 0 :
191 0 : AddressLine1: addr1, // ← 正規化版
192 0 : AddressLine2: addr2, // ← 正規化版
193 0 : AddressLine3: addr3, // ← 正規化版
194 0 :
195 0 : Latitude: p.Latitude,
196 0 : Longitude: p.Longitude,
197 0 : }
198 0 : return a, nil
199 : }
200 :
201 : var (
202 : rePostalJP = regexp.MustCompile(`^\d{3}-?\d{4}$`)
203 :
204 : // ErrInvalidAddress は住所エンティティの生成/検証に失敗したことを示すエラーです。
205 : // errors.Is(err, ErrInvalidAddress) で判定できます。
206 : ErrInvalidAddress = errors.New("invalid address")
207 :
208 : // ErrCoordPair は緯度と経度が「両方指定」または「両方未指定」の条件を満たしていないことを示すエラーです。
209 : // 片方のみ指定された場合に発生します。
210 : errCoordPair = errors.New("latitude/longitude must be both set or both nil")
211 : errLatRange = errors.New("latitude out of range")
212 : errLngRange = errors.New("longitude out of range")
213 : errCountryCode = errors.New("invalid country code")
214 : errJPPrefRequired = errors.New("administrativeArea required for JP")
215 : errJPPrefInvalid = errors.New("invalid Japanese prefecture")
216 : errAddressLine1Required = errors.New("addressLine1 required")
217 : )
218 :
219 : // FieldError は フィールド単位のエラー情報
220 : type FieldError struct {
221 : Field string // JSON名で統一: "countryCode", "latitude" など
222 : Tag string // "required", "coordpair", "range", "jppostal", "jpref_required" etc.
223 : Param string // 追加情報("both_or_none", "[-90,90]", "required_when:countryCode=JP" 等)
224 : }
225 :
226 : // InvalidAddressError は住所が無効である場合の詳細を保持するエラー型です。
227 : // フィールド単位の理由や原因となる値など、追加情報を含められます。
228 : type InvalidAddressError struct {
229 : Problems []FieldError
230 : }
231 :
232 0 : func (e InvalidAddressError) Error() string { return ErrInvalidAddress.Error() }
233 :
234 : // Is は errors.Is(e, target) の判定で ErrInvalidAddress と同一視できるようにします。
235 : // これにより、呼び出し側は詳細型に依存せず「無効な住所」エラーを判定できます。
236 0 : func (e InvalidAddressError) Is(target error) bool {
237 0 : return target == ErrInvalidAddress
238 0 : }
|