Compare commits

...

11 Commits

Author SHA1 Message Date
timonrieger
b1c55883a8 update lock file 2026-02-14 11:46:23 +01:00
timonrieger
864d320e3d revert response validation 2026-02-14 11:44:24 +01:00
timonrieger
832b0ecc8a use enums 2026-02-14 11:17:34 +01:00
timonrieger
3111685f3a map error message 2026-02-13 23:20:03 +01:00
timonrieger
d7cedb329e error handling 2026-02-13 23:12:51 +01:00
timonrieger
93841e1fea rebase 2026-02-13 18:18:03 +01:00
timonrieger
5621cf9fcd chore: remove id fields from OpenAPI schemas and update generation script 2026-02-13 18:16:56 +01:00
timonrieger
280b72f62c set minimum for count fields 2026-02-13 18:16:56 +01:00
timonrieger
0ea71412f3 reuse UserResponseSchema 2026-02-13 18:16:56 +01:00
timonrieger
d86ac3e228 poc 2026-02-13 18:16:50 +01:00
timonrieger
42f1cfd340 setup 2026-02-13 18:16:36 +01:00
21 changed files with 607 additions and 105 deletions

View File

@@ -663,6 +663,7 @@ Class | Method | HTTP request | Description
- [UserPreferencesResponseDto](doc//UserPreferencesResponseDto.md)
- [UserPreferencesUpdateDto](doc//UserPreferencesUpdateDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [UserResponseDtoOutput](doc//UserResponseDtoOutput.md)
- [UserStatus](doc//UserStatus.md)
- [UserUpdateMeDto](doc//UserUpdateMeDto.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)

View File

@@ -402,6 +402,7 @@ part 'model/user_metadata_key.dart';
part 'model/user_preferences_response_dto.dart';
part 'model/user_preferences_update_dto.dart';
part 'model/user_response_dto.dart';
part 'model/user_response_dto_output.dart';
part 'model/user_status.dart';
part 'model/user_update_me_dto.dart';
part 'model/validate_access_token_response_dto.dart';

View File

@@ -136,10 +136,8 @@ class ActivitiesApi {
/// Asset ID (if activity is for an asset)
///
/// * [ReactionLevel] level:
/// Filter by activity level
///
/// * [ReactionType] type:
/// Filter by activity type
///
/// * [String] userId:
/// Filter by user ID
@@ -195,10 +193,8 @@ class ActivitiesApi {
/// Asset ID (if activity is for an asset)
///
/// * [ReactionLevel] level:
/// Filter by activity level
///
/// * [ReactionType] type:
/// Filter by activity type
///
/// * [String] userId:
/// Filter by user ID

View File

@@ -850,6 +850,8 @@ class ApiClient {
return UserPreferencesUpdateDto.fromJson(value);
case 'UserResponseDto':
return UserResponseDto.fromJson(value);
case 'UserResponseDtoOutput':
return UserResponseDtoOutput.fromJson(value);
case 'UserStatus':
return UserStatusTypeTransformer().decode(value);
case 'UserUpdateMeDto':

View File

@@ -40,7 +40,6 @@ class ActivityCreateDto {
///
String? comment;
/// Activity type (like or comment)
ReactionType type;
@override

View File

@@ -33,10 +33,9 @@ class ActivityResponseDto {
/// Activity ID
String id;
/// Activity type
ReactionType type;
UserResponseDto user;
UserResponseDtoOutput user;
@override
bool operator ==(Object other) => identical(this, other) || other is ActivityResponseDto &&
@@ -72,7 +71,9 @@ class ActivityResponseDto {
} else {
// json[r'comment'] = null;
}
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.createdAt.millisecondsSinceEpoch
: this.createdAt.toUtc().toIso8601String();
json[r'id'] = this.id;
json[r'type'] = this.type;
json[r'user'] = this.user;
@@ -90,10 +91,10 @@ class ActivityResponseDto {
return ActivityResponseDto(
assetId: mapValueOfType<String>(json, r'assetId'),
comment: mapValueOfType<String>(json, r'comment'),
createdAt: mapDateTime(json, r'createdAt', r'')!,
createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
id: mapValueOfType<String>(json, r'id')!,
type: ReactionType.fromJson(json[r'type'])!,
user: UserResponseDto.fromJson(json[r'user'])!,
user: UserResponseDtoOutput.fromJson(json[r'user'])!,
);
}
return null;

View File

@@ -18,9 +18,15 @@ class ActivityStatisticsResponseDto {
});
/// Number of comments
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int comments;
/// Number of likes
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int likes;
@override

View File

@@ -10,7 +10,7 @@
part of openapi.api;
/// Reaction level
class ReactionLevel {
/// Instantiate a new enum with the provided [value].
const ReactionLevel._(this.value);

View File

@@ -10,7 +10,7 @@
part of openapi.api;
/// Reaction type
class ReactionType {
/// Instantiate a new enum with the provided [value].
const ReactionType._(this.value);

View File

@@ -0,0 +1,245 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UserResponseDtoOutput {
/// Returns a new [UserResponseDtoOutput] instance.
UserResponseDtoOutput({
required this.avatarColor,
required this.email,
required this.id,
required this.name,
required this.profileChangedAt,
required this.profileImagePath,
});
/// Avatar color
UserResponseDtoOutputAvatarColorEnum avatarColor;
/// User email
String email;
/// User ID
String id;
/// User name
String name;
/// Profile change date
DateTime profileChangedAt;
/// Profile image path
String profileImagePath;
@override
bool operator ==(Object other) => identical(this, other) || other is UserResponseDtoOutput &&
other.avatarColor == avatarColor &&
other.email == email &&
other.id == id &&
other.name == name &&
other.profileChangedAt == profileChangedAt &&
other.profileImagePath == profileImagePath;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatarColor.hashCode) +
(email.hashCode) +
(id.hashCode) +
(name.hashCode) +
(profileChangedAt.hashCode) +
(profileImagePath.hashCode);
@override
String toString() => 'UserResponseDtoOutput[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'avatarColor'] = this.avatarColor;
json[r'email'] = this.email;
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'profileChangedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.profileChangedAt.millisecondsSinceEpoch
: this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
return json;
}
/// Returns a new [UserResponseDtoOutput] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UserResponseDtoOutput? fromJson(dynamic value) {
upgradeDto(value, "UserResponseDtoOutput");
if (value is Map) {
final json = value.cast<String, dynamic>();
return UserResponseDtoOutput(
avatarColor: UserResponseDtoOutputAvatarColorEnum.fromJson(json[r'avatarColor'])!,
email: mapValueOfType<String>(json, r'email')!,
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
);
}
return null;
}
static List<UserResponseDtoOutput> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UserResponseDtoOutput>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UserResponseDtoOutput.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UserResponseDtoOutput> mapFromJson(dynamic json) {
final map = <String, UserResponseDtoOutput>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UserResponseDtoOutput.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UserResponseDtoOutput-objects as value to a dart map
static Map<String, List<UserResponseDtoOutput>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UserResponseDtoOutput>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UserResponseDtoOutput.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'avatarColor',
'email',
'id',
'name',
'profileChangedAt',
'profileImagePath',
};
}
/// Avatar color
class UserResponseDtoOutputAvatarColorEnum {
/// Instantiate a new enum with the provided [value].
const UserResponseDtoOutputAvatarColorEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const primary = UserResponseDtoOutputAvatarColorEnum._(r'primary');
static const pink = UserResponseDtoOutputAvatarColorEnum._(r'pink');
static const red = UserResponseDtoOutputAvatarColorEnum._(r'red');
static const yellow = UserResponseDtoOutputAvatarColorEnum._(r'yellow');
static const blue = UserResponseDtoOutputAvatarColorEnum._(r'blue');
static const green = UserResponseDtoOutputAvatarColorEnum._(r'green');
static const purple = UserResponseDtoOutputAvatarColorEnum._(r'purple');
static const orange = UserResponseDtoOutputAvatarColorEnum._(r'orange');
static const gray = UserResponseDtoOutputAvatarColorEnum._(r'gray');
static const amber = UserResponseDtoOutputAvatarColorEnum._(r'amber');
/// List of all possible values in this [enum][UserResponseDtoOutputAvatarColorEnum].
static const values = <UserResponseDtoOutputAvatarColorEnum>[
primary,
pink,
red,
yellow,
blue,
green,
purple,
orange,
gray,
amber,
];
static UserResponseDtoOutputAvatarColorEnum? fromJson(dynamic value) => UserResponseDtoOutputAvatarColorEnumTypeTransformer().decode(value);
static List<UserResponseDtoOutputAvatarColorEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UserResponseDtoOutputAvatarColorEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UserResponseDtoOutputAvatarColorEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [UserResponseDtoOutputAvatarColorEnum] to String,
/// and [decode] dynamic data back to [UserResponseDtoOutputAvatarColorEnum].
class UserResponseDtoOutputAvatarColorEnumTypeTransformer {
factory UserResponseDtoOutputAvatarColorEnumTypeTransformer() => _instance ??= const UserResponseDtoOutputAvatarColorEnumTypeTransformer._();
const UserResponseDtoOutputAvatarColorEnumTypeTransformer._();
String encode(UserResponseDtoOutputAvatarColorEnum data) => data.value;
/// Decodes a [dynamic value][data] to a UserResponseDtoOutputAvatarColorEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
UserResponseDtoOutputAvatarColorEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'primary': return UserResponseDtoOutputAvatarColorEnum.primary;
case r'pink': return UserResponseDtoOutputAvatarColorEnum.pink;
case r'red': return UserResponseDtoOutputAvatarColorEnum.red;
case r'yellow': return UserResponseDtoOutputAvatarColorEnum.yellow;
case r'blue': return UserResponseDtoOutputAvatarColorEnum.blue;
case r'green': return UserResponseDtoOutputAvatarColorEnum.green;
case r'purple': return UserResponseDtoOutputAvatarColorEnum.purple;
case r'orange': return UserResponseDtoOutputAvatarColorEnum.orange;
case r'gray': return UserResponseDtoOutputAvatarColorEnum.gray;
case r'amber': return UserResponseDtoOutputAvatarColorEnum.amber;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [UserResponseDtoOutputAvatarColorEnumTypeTransformer] instance.
static UserResponseDtoOutputAvatarColorEnumTypeTransformer? _instance;
}

View File

@@ -13,6 +13,7 @@
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -23,6 +24,7 @@
"description": "Asset ID (if activity is for an asset)",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -30,7 +32,6 @@
"name": "level",
"required": false,
"in": "query",
"description": "Filter by activity level",
"schema": {
"$ref": "#/components/schemas/ReactionLevel"
}
@@ -39,7 +40,6 @@
"name": "type",
"required": false,
"in": "query",
"description": "Filter by activity type",
"schema": {
"$ref": "#/components/schemas/ReactionType"
}
@@ -51,6 +51,7 @@
"description": "Filter by user ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
@@ -173,6 +174,7 @@
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
@@ -183,6 +185,7 @@
"description": "Asset ID (if activity is for an asset)",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
@@ -15417,11 +15420,13 @@
"albumId": {
"description": "Album ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"assetId": {
"description": "Asset ID (if activity is for an asset)",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"comment": {
@@ -15429,12 +15434,7 @@
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/ReactionType"
}
],
"description": "Activity type (like or comment)"
"$ref": "#/components/schemas/ReactionType"
}
},
"required": [
@@ -15444,10 +15444,13 @@
"type": "object"
},
"ActivityResponseDto": {
"additionalProperties": false,
"properties": {
"assetId": {
"description": "Asset ID (if activity is for an asset)",
"format": "uuid",
"nullable": true,
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"comment": {
@@ -15458,22 +15461,20 @@
"createdAt": {
"description": "Creation date",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"id": {
"description": "Activity ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/ReactionType"
}
],
"description": "Activity type"
"$ref": "#/components/schemas/ReactionType"
},
"user": {
"$ref": "#/components/schemas/UserResponseDto"
"$ref": "#/components/schemas/UserResponseDto_Output"
}
},
"required": [
@@ -15486,13 +15487,18 @@
"type": "object"
},
"ActivityStatisticsResponseDto": {
"additionalProperties": false,
"properties": {
"comments": {
"description": "Number of comments",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
},
"likes": {
"description": "Number of likes",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
}
},
@@ -20789,6 +20795,7 @@
"type": "object"
},
"ReactionLevel": {
"description": "Reaction level",
"enum": [
"album",
"asset"
@@ -20796,6 +20803,7 @@
"type": "string"
},
"ReactionType": {
"description": "Reaction type",
"enum": [
"comment",
"like"
@@ -25576,6 +25584,60 @@
],
"type": "object"
},
"UserResponseDto_Output": {
"additionalProperties": false,
"properties": {
"avatarColor": {
"description": "Avatar color",
"enum": [
"primary",
"pink",
"red",
"yellow",
"blue",
"green",
"purple",
"orange",
"gray",
"amber"
],
"type": "string"
},
"email": {
"description": "User email",
"type": "string"
},
"id": {
"description": "User ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"name": {
"description": "User name",
"type": "string"
},
"profileChangedAt": {
"description": "Profile change date",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"profileImagePath": {
"description": "Profile image path",
"type": "string"
}
},
"required": [
"avatarColor",
"email",
"id",
"name",
"profileChangedAt",
"profileImagePath"
],
"type": "object"
},
"UserStatus": {
"description": "User status",
"enum": [

View File

@@ -14,9 +14,9 @@ const oazapfts = Oazapfts.runtime(defaults);
export const servers = {
server1: "/api"
};
export type UserResponseDto = {
export type UserResponseDtoOutput = {
/** Avatar color */
avatarColor: UserAvatarColor;
avatarColor: AvatarColor;
/** User email */
email: string;
/** User ID */
@@ -37,9 +37,8 @@ export type ActivityResponseDto = {
createdAt: string;
/** Activity ID */
id: string;
/** Activity type */
"type": ReactionType;
user: UserResponseDto;
user: UserResponseDtoOutput;
};
export type ActivityCreateDto = {
/** Album ID */
@@ -48,7 +47,6 @@ export type ActivityCreateDto = {
assetId?: string;
/** Comment text (required if type is comment) */
comment?: string;
/** Activity type (like or comment) */
"type": ReactionType;
};
export type ActivityStatisticsResponseDto = {
@@ -449,6 +447,20 @@ export type AssetStatsResponseDto = {
/** Number of videos */
videos: number;
};
export type UserResponseDto = {
/** Avatar color */
avatarColor: UserAvatarColor;
/** User email */
email: string;
/** User ID */
id: string;
/** User name */
name: string;
/** Profile change date */
profileChangedAt: string;
/** Profile image path */
profileImagePath: string;
};
export type AlbumUserResponseDto = {
/** Album user role */
role: AlbumUserRole;
@@ -6782,7 +6794,7 @@ export enum ReactionType {
Comment = "comment",
Like = "like"
}
export enum UserAvatarColor {
export enum AvatarColor {
Primary = "primary",
Pink = "pink",
Red = "red",
@@ -6822,6 +6834,18 @@ export enum NotificationType {
AlbumUpdate = "AlbumUpdate",
Custom = "Custom"
}
export enum UserAvatarColor {
Primary = "primary",
Pink = "pink",
Red = "red",
Yellow = "yellow",
Blue = "blue",
Green = "green",
Purple = "purple",
Orange = "orange",
Gray = "gray",
Amber = "amber"
}
export enum UserStatus {
Active = "active",
Removing = "removing",

31
pnpm-lock.yaml generated
View File

@@ -511,6 +511,9 @@ importers:
nestjs-otel:
specifier: ^7.0.0
version: 7.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
nestjs-zod:
specifier: ^5.1.1
version: 5.1.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6)
nodemailer:
specifier: ^7.0.0
version: 7.0.13
@@ -580,6 +583,9 @@ importers:
validator:
specifier: ^13.12.0
version: 13.15.26
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@eslint/js':
specifier: ^9.8.0
@@ -9201,6 +9207,17 @@ packages:
'@nestjs/common': '>= 11 < 12'
'@nestjs/core': '>= 11 < 12'
nestjs-zod@5.1.1:
resolution: {integrity: sha512-pXa9Jrdip7iedKvGxJTvvCFVRCoIcNENPCsHjpCefPH3PcFejRgkZkUcr3TYITRyxnUk7Zy5OsLpirZGLYBfBQ==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0
rxjs: ^7.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
'@nestjs/swagger':
optional: true
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
@@ -12283,6 +12300,9 @@ packages:
zod@4.2.1:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zwitch@1.0.5:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
@@ -22196,6 +22216,15 @@ snapshots:
response-time: 2.3.4
tslib: 2.8.1
nestjs-zod@5.1.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6):
dependencies:
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1
rxjs: 7.8.2
zod: 4.3.6
optionalDependencies:
'@nestjs/swagger': 11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
next-tick@1.1.0: {}
no-case@3.0.4:
@@ -25925,6 +25954,8 @@ snapshots:
zod@4.2.1: {}
zod@4.3.6: {}
zwitch@1.0.5: {}
zwitch@2.0.4: {}

View File

@@ -91,6 +91,7 @@
"nestjs-cls": "^5.0.0",
"nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0",
"nestjs-zod": "^5.1.1",
"nodemailer": "^7.0.0",
"openid-client": "^6.3.3",
"pg": "^8.11.3",
@@ -113,7 +114,8 @@
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0"
"validator": "^13.12.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.8.0",

View File

@@ -1,10 +1,11 @@
import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { Inject, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
@@ -41,7 +42,8 @@ const common = [...repositories, ...services, GlobalExceptionFilter];
const commonMiddleware = [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_PIPE, useClass: ZodValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
];

View File

@@ -73,4 +73,4 @@ export class ActivityController {
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}
}

View File

@@ -1,84 +1,111 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { createZodDto } from 'nestjs-zod';
import { Activity } from 'src/database';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { ValidateEnum, ValidateUUID } from 'src/validation';
export enum ReactionType {
COMMENT = 'comment',
LIKE = 'like',
}
import { UserResponseSchema } from 'src/dtos/user.dto';
import { UserAvatarColor } from 'src/enum';
import { z } from 'zod';
// Option 1 - reuse existing enum:
export enum ReactionLevel {
ALBUM = 'album',
ASSET = 'asset',
}
export const ReactionLevelSchema = z.enum(ReactionLevel).describe('Reaction level').meta({ id: 'ReactionLevel' });
// Option 2 - define inline and access via .enum.XXX:
export enum ReactionType {
COMMENT = 'comment',
LIKE = 'like',
}
export const ReactionTypeSchema = z.enum(ReactionType).describe('Reaction type').meta({ id: 'ReactionType' });
export type MaybeDuplicate<T> = { duplicate: boolean; value: T };
export class ActivityResponseDto {
@ApiProperty({ description: 'Activity ID' })
id!: string;
@ApiProperty({ description: 'Creation date', format: 'date-time' })
createdAt!: Date;
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type' })
type!: ReactionType;
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
user!: UserResponseDto;
@ApiProperty({ description: 'Asset ID (if activity is for an asset)' })
assetId!: string | null;
@ApiPropertyOptional({ description: 'Comment text (for comment activities)' })
comment?: string | null;
}
const ActivityResponseSchema = z
.object({
id: z.uuidv4().describe('Activity ID'),
createdAt: z.iso.datetime().describe('Creation date'),
user: UserResponseSchema,
assetId: z.uuidv4().describe('Asset ID (if activity is for an asset)').nullable(),
type: ReactionTypeSchema,
comment: z.string().describe('Comment text (for comment activities)').nullish(),
})
.describe('Activity response');
export class ActivityStatisticsResponseDto {
@ApiProperty({ type: 'integer', description: 'Number of comments' })
comments!: number;
const ActivityStatisticsResponseSchema = z
.object({
comments: z.number().int().min(0).describe('Number of comments'),
likes: z.number().int().min(0).describe('Number of likes'),
})
.describe('Activity statistics response');
@ApiProperty({ type: 'integer', description: 'Number of likes' })
likes!: number;
}
const ActivitySchema = z
.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().describe('Asset ID (if activity is for an asset)').optional(),
})
.describe('Activity');
export class ActivityDto {
@ValidateUUID({ description: 'Album ID' })
albumId!: string;
const ActivitySearchSchema = ActivitySchema.extend({
type: ReactionTypeSchema.optional(),
level: ReactionLevelSchema.optional(),
userId: z.uuidv4().describe('Filter by user ID').optional(),
}).describe('Activity search');
@ValidateUUID({ optional: true, description: 'Asset ID (if activity is for an asset)' })
assetId?: string;
}
export class ActivitySearchDto extends ActivityDto {
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Filter by activity type', optional: true })
type?: ReactionType;
@ValidateEnum({ enum: ReactionLevel, name: 'ReactionLevel', description: 'Filter by activity level', optional: true })
level?: ReactionLevel;
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
userId?: string;
}
const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT;
export class ActivityCreateDto extends ActivityDto {
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type (like or comment)' })
type!: ReactionType;
@ApiPropertyOptional({ description: 'Comment text (required if type is comment)' })
@ValidateIf(isComment)
@IsNotEmpty()
@IsString()
comment?: string;
}
const ActivityCreateSchema = ActivitySchema.extend({
type: ReactionTypeSchema,
assetId: z.uuidv4().describe('Asset ID (if activity is for an asset)').optional(),
comment: z.string().describe('Comment text (required if type is comment)').optional(),
})
.refine((data) => data.type !== ReactionType.COMMENT || (data.comment && data.comment.trim() !== ''), {
message: 'Comment is required when type is COMMENT',
path: ['comment'],
})
.refine((data) => data.type === ReactionType.COMMENT || !data.comment, {
message: 'Comment must not be provided when type is not COMMENT',
path: ['comment'],
})
.describe('Activity create');
export const mapActivity = (activity: Activity): ActivityResponseDto => {
const type = activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT;
if (type === ReactionType.COMMENT) {
return {
id: activity.id,
assetId: activity.assetId,
createdAt: activity.createdAt.toISOString(),
type: ReactionType.COMMENT,
user: {
id: activity.user.id,
name: activity.user.name,
email: activity.user.email,
profileImagePath: activity.user.profileImagePath,
avatarColor: activity.user.avatarColor ?? UserAvatarColor.Primary,
profileChangedAt: new Date(activity.user.profileChangedAt).toISOString(),
},
comment: activity.comment,
};
}
return {
id: activity.id,
assetId: activity.assetId,
createdAt: activity.createdAt,
comment: activity.comment,
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
user: mapUser(activity.user),
createdAt: activity.createdAt.toISOString(),
type: ReactionType.LIKE,
user: {
id: activity.user.id,
name: activity.user.name,
email: activity.user.email,
profileImagePath: activity.user.profileImagePath,
avatarColor: activity.user.avatarColor ?? UserAvatarColor.Primary,
profileChangedAt: new Date(activity.user.profileChangedAt).toISOString(),
},
comment: null,
};
};
export class ActivityResponseDto extends createZodDto(ActivityResponseSchema) {}
export class ActivityCreateDto extends createZodDto(ActivityCreateSchema) {}
export class ActivityDto extends createZodDto(ActivitySchema) {}
export class ActivitySearchDto extends createZodDto(ActivitySearchSchema) {}
export class ActivityStatisticsResponseDto extends createZodDto(ActivityStatisticsResponseSchema) {}

View File

@@ -5,6 +5,7 @@ import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
import { z } from 'zod';
export class UserUpdateMeDto {
@ApiPropertyOptional({ description: 'User email' })
@@ -51,6 +52,15 @@ export class UserResponseDto {
profileChangedAt!: Date;
}
export const UserResponseSchema = z.object({
id: z.uuidv4().describe('User ID'),
name: z.string().describe('User name'),
email: z.string().describe('User email'),
profileImagePath: z.string().describe('Profile image path'),
avatarColor: z.enum(UserAvatarColor).describe('Avatar color'),
profileChangedAt: z.iso.datetime().describe('Profile change date'),
}).meta({ id: 'UserResponseDto' });
export class UserLicense {
@ApiProperty({ description: 'License key' })
licenseKey!: string;

View File

@@ -1,8 +1,10 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter<Error> {
@@ -41,6 +43,20 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
body = { message: body };
}
// handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) {
body = {
message: zodError.issues.map((issue) => issue.message),
// technically more accurate to return 422 Unprocessable Entity here
// nestjs-zod uses 400 Bad Request and we use 400 in v2
// https://github.com/BenLorantfy/nestjs-zod/issues/328
error: 'Bad Request',
};
}
}
return { status, body };
}

View File

@@ -47,7 +47,9 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]);
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual(
[],
);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
});

View File

@@ -12,6 +12,7 @@ import {
SchemaObject,
} from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import _ from 'lodash';
import { cleanupOpenApiDoc } from 'nestjs-zod';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import picomatch from 'picomatch';
@@ -149,6 +150,43 @@ function sortKeys<T>(target: T): T {
export const routeToErrorMessage = (methodName: string) =>
'Failed to ' + methodName.replaceAll(/[A-Z]+/g, (letter) => ` ${letter.toLowerCase()}`);
const stripSchemaMetadata = (schema: unknown) => {
if (!schema || typeof schema !== 'object') {
return schema;
}
const clone = _.cloneDeep(schema) as Record<string, unknown>;
delete clone.id;
return clone;
};
const replaceSchemaRefs = (target: unknown, schemaNameMap: Record<string, string>) => {
if (!target || typeof target !== 'object') {
return;
}
if (Array.isArray(target)) {
for (const item of target) {
replaceSchemaRefs(item, schemaNameMap);
}
return;
}
const obj = target as Record<string, unknown>;
const ref = obj.$ref;
if (typeof ref === 'string' && ref.startsWith('#/components/schemas/')) {
const name = ref.slice('#/components/schemas/'.length);
const mapped = schemaNameMap[name];
if (mapped) {
obj.$ref = `#/components/schemas/${mapped}`;
}
}
for (const value of Object.values(obj)) {
replaceSchemaRefs(value, schemaNameMap);
}
};
const isSchema = (schema: string | ReferenceObject | SchemaObject): schema is SchemaObject => {
if (typeof schema === 'string' || '$ref' in schema) {
return false;
@@ -162,10 +200,46 @@ const patchOpenAPI = (document: OpenAPIObject) => {
if (document.components?.schemas) {
const schemas = document.components.schemas as Record<string, SchemaObject>;
const schemaNameMap: Record<string, string> = {};
/**
If X_Output exists and X does not exist → rename X_Output to X and rewrite all $refs.
If both exist and are deep-equal → rewrite refs to X, delete X_Output.
If both exist and differ → keep both (this is the "real" reason _Output exists).
*/
for (const outputName of Object.keys(schemas).filter((name) => name.endsWith('_Output'))) {
const baseName = outputName.slice(0, -'_Output'.length);
const outputSchema = schemas[outputName];
const baseSchema = schemas[baseName];
if (!baseSchema) {
schemas[baseName] = outputSchema;
delete schemas[outputName];
schemaNameMap[outputName] = baseName;
const id = (outputSchema as Record<string, unknown>).id;
if (id === outputName) {
(outputSchema as Record<string, unknown>).id = baseName;
}
continue;
}
if (_.isEqual(stripSchemaMetadata(baseSchema), stripSchemaMetadata(outputSchema))) {
delete schemas[outputName];
schemaNameMap[outputName] = baseName;
}
}
replaceSchemaRefs(document, schemaNameMap);
for (const schema of Object.values(schemas)) {
delete (schema as Record<string, unknown>).id;
}
document.components.schemas = sortKeys(schemas);
for (const [schemaName, schema] of Object.entries(schemas)) {
for (const [schemaName, schema] of Object.entries(document.components.schemas as Record<string, SchemaObject>)) {
if (schema.properties) {
schema.properties = sortKeys(schema.properties);
@@ -265,6 +339,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
};
const specification = SwaggerModule.createDocument(app, config, options);
const openApiDoc = cleanupOpenApiDoc(specification);
const customOptions: SwaggerCustomOptions = {
swaggerOptions: {
@@ -275,12 +350,12 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
customSiteTitle: 'Immich API Documentation',
};
SwaggerModule.setup('doc', app, specification, customOptions);
SwaggerModule.setup('doc', app, openApiDoc, customOptions);
if (write) {
// Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(openApiDoc), null, 2), { encoding: 'utf8' });
}
};