diff --git a/.forgejo/workflows/build-and-deploy.yml b/.forgejo/workflows/build-and-deploy.yml index e7d0a83..3c473d2 100644 --- a/.forgejo/workflows/build-and-deploy.yml +++ b/.forgejo/workflows/build-and-deploy.yml @@ -18,14 +18,10 @@ jobs: - uses: actions/setup-go@v6 with: go-version: '^1.25' - check-latest: false - cache-dependency-path: | - modules/backend/go.sum - - name: Build Go app + - name: Build backend run: | cd modules/backend - go mod tidy go build -o nyanimedb . tar -czvf nyanimedb-backend.tar.gz nyanimedb @@ -35,6 +31,18 @@ jobs: name: nyanimedb-backend.tar.gz path: modules/backend/nyanimedb-backend.tar.gz + - name: Build auth + run: | + cd modules/auth + go build -o auth . + tar -czvf nyanimedb-auth.tar.gz auth + + - name: Upload built auth to artifactory + uses: actions/upload-artifact@v3 + with: + name: nyanimedb-auth.tar.gz + path: modules/auth/nyanimedb-auth.tar.gz + # Build frontend - uses: actions/setup-node@v5 with: @@ -76,6 +84,14 @@ jobs: push: true tags: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest + - name: Build and push auth image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfiles/Dockerfile_auth + push: true + tags: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + - name: Build and push frontend image uses: docker/build-push-action@v6 with: @@ -85,7 +101,7 @@ jobs: tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest deploy: - runs-on: self-hosted + runs-on: debian-test needs: build env: POSTGRES_USER: ${{ secrets.POSTGRES_USER }} diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml index 5ff77e0..e85ddf9 100644 --- a/api/_build/openapi.yaml +++ b/api/_build/openapi.yaml @@ -11,47 +11,57 @@ paths: parameters: - $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/title_sort' - - in: query - name: sort_forward + - name: sort_forward + in: query schema: type: boolean default: true - - in: query - name: word + - name: ext_search + in: query + schema: + type: boolean + default: false + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: - $ref: '#/components/schemas/TitleStatus' - - in: query - name: rating + type: array + items: + $ref: '#/components/schemas/TitleStatus' + explode: false + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: release_year + - name: release_year + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: offset + - name: offset + in: query schema: type: integer format: int32 default: 0 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -64,10 +74,10 @@ paths: type: object properties: data: + description: List of titles type: array items: $ref: '#/components/schemas/Title' - description: List of titles cursor: $ref: '#/components/schemas/CursorObj' required: @@ -81,16 +91,17 @@ paths: description: Unknown server error '/titles/{title_id}': get: + operationId: getTitle summary: Get title description parameters: - - in: path - name: title_id + - name: title_id + in: path required: true schema: type: integer format: int64 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -111,15 +122,16 @@ paths: description: Unknown server error '/users/{user_id}': get: + operationId: getUsersId summary: Get user info parameters: - - in: path - name: user_id + - name: user_id + in: path required: true schema: type: string - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -136,50 +148,146 @@ paths: description: User not found '500': description: Unknown server error - '/users/{user_id}/titles/': + patch: + operationId: updateUser + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + parameters: + - name: user_id + in: path + description: User ID (primary key) + required: true + schema: + type: integer + format: int64 + example: 123 + requestBody: + required: true + content: + application/json: + schema: + description: Only provided fields are updated. Omitted fields remain unchanged. + type: object + properties: + avatar_id: + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + type: integer + format: int64 + example: 42 + nullable: true + mail: + description: User email (must be unique and valid) + type: string + format: email + example: john.doe.updated@example.com + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + nickname: + description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)' + type: string + example: john_doe_43 + maxLength: 16 + minLength: 3 + pattern: '^[a-zA-Z0-9_-]{3,16}$' + disp_name: + description: Display name + type: string + example: John Smith + maxLength: 32 + user_desc: + description: User description / bio + type: string + example: Just a curious developer. + maxLength: 512 + additionalProperties: false + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: 'Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)' + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: 'Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)' + '404': + description: User not found + '409': + description: 'Conflict — e.g., requested `nickname` or `mail` already taken by another user' + '422': + description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)' + '500': + description: Unknown server error + '/users/{user_id}/titles': get: + operationId: getUserTitles summary: Get user titles parameters: - $ref: '#/components/parameters/cursor' - - in: path - name: user_id + - $ref: '#/components/parameters/title_sort' + - name: user_id + in: path required: true schema: type: string - - in: query - name: word + - name: sort_forward + in: query + schema: + type: boolean + default: true + - name: word + in: query schema: type: string - - in: query - name: status + - name: status + in: query + description: List of title statuses to filter schema: - $ref: '#/components/schemas/TitleStatus' - - in: query - name: watch_status + type: array + items: + $ref: '#/components/schemas/TitleStatus' + explode: false + style: form + - name: watch_status + in: query schema: - $ref: '#/components/schemas/UserTitleStatus' - - in: query - name: rating + type: array + items: + $ref: '#/components/schemas/UserTitleStatus' + explode: false + style: form + - name: rating + in: query schema: type: number format: double - - in: query - name: release_year + - name: my_rate + in: query schema: type: integer format: int32 - - in: query - name: release_season + - name: release_year + in: query + schema: + type: integer + format: int32 + - name: release_season + in: query schema: $ref: '#/components/schemas/ReleaseSeason' - - in: query - name: limit + - name: limit + in: query schema: type: integer format: int32 default: 10 - - in: query - name: fields + - name: fields + in: query schema: type: string default: all @@ -189,15 +297,181 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/UserTitle' + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserTitle' + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor '204': description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error + post: + operationId: addUserTitle + summary: Add a title to a user + description: 'User adding title to list af watched, status required' + parameters: + - name: user_id + in: path + description: ID of the user to assign the title to + required: true + schema: + type: integer + format: int64 + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + required: + - title_id + - status + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error + '/users/{user_id}/titles/{title_id}': + get: + operationId: getUserTitle + summary: Get user title + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error + patch: + operationId: updateUserTitle + summary: Update a usertitle + description: User updating title list of watched + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '#/components/schemas/UserTitleMini' + '400': + description: 'Invalid request body (missing fields, invalid types, etc.)' + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error + delete: + operationId: deleteUserTitle + summary: Delete a usertitle + description: User deleting title from list of watched + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + - name: title_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Title successfully deleted + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found + '500': + description: Internal server error components: parameters: cursor: @@ -213,25 +487,36 @@ components: schema: $ref: '#/components/schemas/TitleSort' schemas: - CursorObj: - type: object - required: - - id - properties: - id: - type: integer - format: int64 - param: - type: string TitleSort: - type: string description: Title sort order + type: string default: id enum: - id - year - rating - views + TitleStatus: + description: Title status + type: string + enum: + - finished + - ongoing + - planned + ReleaseSeason: + description: Title release season + type: string + enum: + - winter + - spring + - summer + - fall + StorageType: + description: Image storage type + type: string + enum: + - s3 + - local Image: type: object properties: @@ -239,61 +524,11 @@ components: type: integer format: int64 storage_type: - type: string + $ref: '#/components/schemas/StorageType' image_path: type: string - TitleStatus: - type: string - description: Title status - enum: - - finished - - ongoing - - planned - ReleaseSeason: - type: string - description: Title release season - enum: - - winter - - spring - - summer - - fall - UserTitleStatus: - type: string - description: User's title status - enum: - - finished - - planned - - dropped - - in-progress - Review: - type: object - additionalProperties: true - Tag: - type: object - description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' - additionalProperties: - type: string - example: - en: Shojo - ru: Сёдзё - ja: 少女 - Tags: - type: array - description: Array of localized tags - items: - $ref: '#/components/schemas/Tag' - example: - - en: Shojo - ru: Сёдзё - ja: 少女 - - en: Shounen - ru: Сёнен - ja: 少年 Studio: type: object - required: - - id - - name properties: id: type: integer @@ -304,30 +539,41 @@ components: $ref: '#/components/schemas/Image' description: type: string - Title: - type: object required: - id - - title_names - - tags + - name + Tag: + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + type: object + example: + en: Shojo + ru: Сёдзё + ja: 少女 + additionalProperties: + type: string + Tags: + description: Array of localized tags + type: array + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Title: + type: object properties: id: + description: Unique title ID (primary key) type: integer format: int64 - description: Unique title ID (primary key) example: 1 title_names: - type: object description: 'Localized titles. Key = language (ISO 639-1), value = list of names' - additionalProperties: - type: array - items: - type: string - example: Attack on Titan - minItems: 1 - example: - - Attack on Titan - - AoT + type: object example: en: - Attack on Titan @@ -337,6 +583,15 @@ components: - Титаны ja: - 進撃の巨人 + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT studio: $ref: '#/components/schemas/Studio' tags: @@ -367,54 +622,91 @@ components: additionalProperties: type: number format: double - additionalProperties: true - User: + required: + - id + - title_names + - tags + CursorObj: type: object properties: id: type: integer format: int64 + param: + type: string + required: + - id + User: + type: object + properties: + id: description: Unique user ID (primary key) - example: 1 - avatar_id: type: integer format: int64 - description: ID of the user avatar (references images table) - example: null + example: 1 + image: + $ref: '#/components/schemas/Image' mail: + description: User email type: string format: email - description: User email example: john.doe@example.com nickname: - type: string description: Username (alphanumeric + _ or -) - maxLength: 16 + type: string example: john_doe_42 + maxLength: 16 disp_name: - type: string description: Display name - maxLength: 32 - example: John Doe - user_desc: type: string + example: John Doe + maxLength: 32 + user_desc: description: User description - maxLength: 512 + type: string example: Just a regular user. + maxLength: 512 creation_date: + description: Timestamp when the user was created type: string format: date-time - description: Timestamp when the user was created example: '2025-10-10T23:45:47.908073Z' required: - user_id - nickname + UserTitleStatus: + description: User's title status + type: string + enum: + - finished + - planned + - dropped + - in-progress UserTitle: type: object + properties: + user_id: + type: integer + format: int64 + title: + $ref: '#/components/schemas/Title' + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time required: - user_id - title_id - status + UserTitleMini: + type: object properties: user_id: type: integer @@ -433,4 +725,10 @@ components: ctime: type: string format: date-time + required: + - user_id + - title_id + - status + Review: + type: object additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index f252a5a..c8fd9aa 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -24,6 +24,12 @@ const ( Winter ReleaseSeason = "winter" ) +// Defines values for StorageType. +const ( + Local StorageType = "local" + S3 StorageType = "s3" +) + // Defines values for TitleSort. const ( Id TitleSort = "id" @@ -55,14 +61,19 @@ type CursorObj struct { // Image defines model for Image. type Image struct { - Id *int64 `json:"id,omitempty"` - ImagePath *string `json:"image_path,omitempty"` - StorageType *string `json:"storage_type,omitempty"` + Id *int64 `json:"id,omitempty"` + ImagePath *string `json:"image_path,omitempty"` + + // StorageType Image storage type + StorageType *StorageType `json:"storage_type,omitempty"` } // ReleaseSeason Title release season type ReleaseSeason string +// StorageType Image storage type +type StorageType string + // Studio defines model for Studio. type Studio struct { Description *string `json:"description,omitempty"` @@ -101,8 +112,7 @@ type Title struct { TitleNames map[string][]string `json:"title_names"` // TitleStatus Title status - TitleStatus *TitleStatus `json:"title_status,omitempty"` - AdditionalProperties map[string]interface{} `json:"-"` + TitleStatus *TitleStatus `json:"title_status,omitempty"` } // TitleSort Title sort order @@ -113,9 +123,6 @@ type TitleStatus string // User defines model for User. type User struct { - // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id,omitempty"` - // CreationDate Timestamp when the user was created CreationDate *time.Time `json:"creation_date,omitempty"` @@ -123,7 +130,8 @@ type User struct { DispName *string `json:"disp_name,omitempty"` // Id Unique user ID (primary key) - Id *int64 `json:"id,omitempty"` + Id *int64 `json:"id,omitempty"` + Image *Image `json:"image,omitempty"` // Mail User email Mail *openapi_types.Email `json:"mail,omitempty"` @@ -142,10 +150,21 @@ type UserTitle struct { ReviewId *int64 `json:"review_id,omitempty"` // Status User's title status - Status UserTitleStatus `json:"status"` - TitleId int64 `json:"title_id"` - UserId int64 `json:"user_id"` - AdditionalProperties map[string]interface{} `json:"-"` + Status UserTitleStatus `json:"status"` + Title *Title `json:"title,omitempty"` + UserId int64 `json:"user_id"` +} + +// UserTitleMini defines model for UserTitleMini. +type UserTitleMini struct { + Ctime *time.Time `json:"ctime,omitempty"` + Rate *int32 `json:"rate,omitempty"` + ReviewId *int64 `json:"review_id,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` + UserId int64 `json:"user_id"` } // UserTitleStatus User's title status @@ -156,11 +175,14 @@ type Cursor = string // GetTitlesParams defines parameters for GetTitles. type GetTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` - SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` - Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + ExtSearch *bool `form:"ext_search,omitempty" json:"ext_search,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` @@ -169,407 +191,77 @@ type GetTitlesParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetTitlesTitleIdParams defines parameters for GetTitlesTitleId. -type GetTitlesTitleIdParams struct { +// GetTitleParams defines parameters for GetTitle. +type GetTitleParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetUsersUserIdParams defines parameters for GetUsersUserId. -type GetUsersUserIdParams struct { +// GetUsersIdParams defines parameters for GetUsersId. +type GetUsersIdParams struct { Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// GetUsersUserIdTitlesParams defines parameters for GetUsersUserIdTitles. -type GetUsersUserIdTitlesParams struct { - Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` - Word *string `form:"word,omitempty" json:"word,omitempty"` - Status *TitleStatus `form:"status,omitempty" json:"status,omitempty"` - WatchStatus *UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` - Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` - ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` - ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` - Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` - Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +// UpdateUserJSONBody defines parameters for UpdateUser. +type UpdateUserJSONBody struct { + // AvatarId ID of the user avatar (references `images.id`); set to `null` to remove avatar + AvatarId *int64 `json:"avatar_id"` + + // DispName Display name + DispName *string `json:"disp_name,omitempty"` + + // Mail User email (must be unique and valid) + Mail *openapi_types.Email `json:"mail,omitempty"` + + // Nickname Username (alphanumeric + `_` or `-`, 3–16 chars) + Nickname *string `json:"nickname,omitempty"` + + // UserDesc User description / bio + UserDesc *string `json:"user_desc,omitempty"` } -// Getter for additional properties for Title. Returns the specified -// element and whether it was found -func (a Title) Get(fieldName string) (value interface{}, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return +// GetUserTitlesParams defines parameters for GetUserTitles. +type GetUserTitlesParams struct { + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Sort *TitleSort `form:"sort,omitempty" json:"sort,omitempty"` + SortForward *bool `form:"sort_forward,omitempty" json:"sort_forward,omitempty"` + Word *string `form:"word,omitempty" json:"word,omitempty"` + + // Status List of title statuses to filter + Status *[]TitleStatus `form:"status,omitempty" json:"status,omitempty"` + WatchStatus *[]UserTitleStatus `form:"watch_status,omitempty" json:"watch_status,omitempty"` + Rating *float64 `form:"rating,omitempty" json:"rating,omitempty"` + MyRate *int32 `form:"my_rate,omitempty" json:"my_rate,omitempty"` + ReleaseYear *int32 `form:"release_year,omitempty" json:"release_year,omitempty"` + ReleaseSeason *ReleaseSeason `form:"release_season,omitempty" json:"release_season,omitempty"` + Limit *int32 `form:"limit,omitempty" json:"limit,omitempty"` + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } -// Setter for additional properties for Title -func (a *Title) Set(fieldName string, value interface{}) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]interface{}) - } - a.AdditionalProperties[fieldName] = value +// AddUserTitleJSONBody defines parameters for AddUserTitle. +type AddUserTitleJSONBody struct { + Rate *int32 `json:"rate,omitempty"` + + // Status User's title status + Status UserTitleStatus `json:"status"` + TitleId int64 `json:"title_id"` } -// Override default JSON handling for Title to handle AdditionalProperties -func (a *Title) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } +// UpdateUserTitleJSONBody defines parameters for UpdateUserTitle. +type UpdateUserTitleJSONBody struct { + Rate *int32 `json:"rate,omitempty"` - if raw, found := object["episodes_aired"]; found { - err = json.Unmarshal(raw, &a.EpisodesAired) - if err != nil { - return fmt.Errorf("error reading 'episodes_aired': %w", err) - } - delete(object, "episodes_aired") - } - - if raw, found := object["episodes_all"]; found { - err = json.Unmarshal(raw, &a.EpisodesAll) - if err != nil { - return fmt.Errorf("error reading 'episodes_all': %w", err) - } - delete(object, "episodes_all") - } - - if raw, found := object["episodes_len"]; found { - err = json.Unmarshal(raw, &a.EpisodesLen) - if err != nil { - return fmt.Errorf("error reading 'episodes_len': %w", err) - } - delete(object, "episodes_len") - } - - if raw, found := object["id"]; found { - err = json.Unmarshal(raw, &a.Id) - if err != nil { - return fmt.Errorf("error reading 'id': %w", err) - } - delete(object, "id") - } - - if raw, found := object["poster"]; found { - err = json.Unmarshal(raw, &a.Poster) - if err != nil { - return fmt.Errorf("error reading 'poster': %w", err) - } - delete(object, "poster") - } - - if raw, found := object["rating"]; found { - err = json.Unmarshal(raw, &a.Rating) - if err != nil { - return fmt.Errorf("error reading 'rating': %w", err) - } - delete(object, "rating") - } - - if raw, found := object["rating_count"]; found { - err = json.Unmarshal(raw, &a.RatingCount) - if err != nil { - return fmt.Errorf("error reading 'rating_count': %w", err) - } - delete(object, "rating_count") - } - - if raw, found := object["release_season"]; found { - err = json.Unmarshal(raw, &a.ReleaseSeason) - if err != nil { - return fmt.Errorf("error reading 'release_season': %w", err) - } - delete(object, "release_season") - } - - if raw, found := object["release_year"]; found { - err = json.Unmarshal(raw, &a.ReleaseYear) - if err != nil { - return fmt.Errorf("error reading 'release_year': %w", err) - } - delete(object, "release_year") - } - - if raw, found := object["studio"]; found { - err = json.Unmarshal(raw, &a.Studio) - if err != nil { - return fmt.Errorf("error reading 'studio': %w", err) - } - delete(object, "studio") - } - - if raw, found := object["tags"]; found { - err = json.Unmarshal(raw, &a.Tags) - if err != nil { - return fmt.Errorf("error reading 'tags': %w", err) - } - delete(object, "tags") - } - - if raw, found := object["title_names"]; found { - err = json.Unmarshal(raw, &a.TitleNames) - if err != nil { - return fmt.Errorf("error reading 'title_names': %w", err) - } - delete(object, "title_names") - } - - if raw, found := object["title_status"]; found { - err = json.Unmarshal(raw, &a.TitleStatus) - if err != nil { - return fmt.Errorf("error reading 'title_status': %w", err) - } - delete(object, "title_status") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]interface{}) - for fieldName, fieldBuf := range object { - var fieldVal interface{} - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil + // Status User's title status + Status *UserTitleStatus `json:"status,omitempty"` } -// Override default JSON handling for Title to handle AdditionalProperties -func (a Title) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) +// UpdateUserJSONRequestBody defines body for UpdateUser for application/json ContentType. +type UpdateUserJSONRequestBody UpdateUserJSONBody - if a.EpisodesAired != nil { - object["episodes_aired"], err = json.Marshal(a.EpisodesAired) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_aired': %w", err) - } - } +// AddUserTitleJSONRequestBody defines body for AddUserTitle for application/json ContentType. +type AddUserTitleJSONRequestBody AddUserTitleJSONBody - if a.EpisodesAll != nil { - object["episodes_all"], err = json.Marshal(a.EpisodesAll) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_all': %w", err) - } - } - - if a.EpisodesLen != nil { - object["episodes_len"], err = json.Marshal(a.EpisodesLen) - if err != nil { - return nil, fmt.Errorf("error marshaling 'episodes_len': %w", err) - } - } - - object["id"], err = json.Marshal(a.Id) - if err != nil { - return nil, fmt.Errorf("error marshaling 'id': %w", err) - } - - if a.Poster != nil { - object["poster"], err = json.Marshal(a.Poster) - if err != nil { - return nil, fmt.Errorf("error marshaling 'poster': %w", err) - } - } - - if a.Rating != nil { - object["rating"], err = json.Marshal(a.Rating) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rating': %w", err) - } - } - - if a.RatingCount != nil { - object["rating_count"], err = json.Marshal(a.RatingCount) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rating_count': %w", err) - } - } - - if a.ReleaseSeason != nil { - object["release_season"], err = json.Marshal(a.ReleaseSeason) - if err != nil { - return nil, fmt.Errorf("error marshaling 'release_season': %w", err) - } - } - - if a.ReleaseYear != nil { - object["release_year"], err = json.Marshal(a.ReleaseYear) - if err != nil { - return nil, fmt.Errorf("error marshaling 'release_year': %w", err) - } - } - - if a.Studio != nil { - object["studio"], err = json.Marshal(a.Studio) - if err != nil { - return nil, fmt.Errorf("error marshaling 'studio': %w", err) - } - } - - object["tags"], err = json.Marshal(a.Tags) - if err != nil { - return nil, fmt.Errorf("error marshaling 'tags': %w", err) - } - - object["title_names"], err = json.Marshal(a.TitleNames) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_names': %w", err) - } - - if a.TitleStatus != nil { - object["title_status"], err = json.Marshal(a.TitleStatus) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_status': %w", err) - } - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} - -// Getter for additional properties for UserTitle. Returns the specified -// element and whether it was found -func (a UserTitle) Get(fieldName string) (value interface{}, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return -} - -// Setter for additional properties for UserTitle -func (a *UserTitle) Set(fieldName string, value interface{}) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]interface{}) - } - a.AdditionalProperties[fieldName] = value -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a *UserTitle) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["ctime"]; found { - err = json.Unmarshal(raw, &a.Ctime) - if err != nil { - return fmt.Errorf("error reading 'ctime': %w", err) - } - delete(object, "ctime") - } - - if raw, found := object["rate"]; found { - err = json.Unmarshal(raw, &a.Rate) - if err != nil { - return fmt.Errorf("error reading 'rate': %w", err) - } - delete(object, "rate") - } - - if raw, found := object["review_id"]; found { - err = json.Unmarshal(raw, &a.ReviewId) - if err != nil { - return fmt.Errorf("error reading 'review_id': %w", err) - } - delete(object, "review_id") - } - - if raw, found := object["status"]; found { - err = json.Unmarshal(raw, &a.Status) - if err != nil { - return fmt.Errorf("error reading 'status': %w", err) - } - delete(object, "status") - } - - if raw, found := object["title_id"]; found { - err = json.Unmarshal(raw, &a.TitleId) - if err != nil { - return fmt.Errorf("error reading 'title_id': %w", err) - } - delete(object, "title_id") - } - - if raw, found := object["user_id"]; found { - err = json.Unmarshal(raw, &a.UserId) - if err != nil { - return fmt.Errorf("error reading 'user_id': %w", err) - } - delete(object, "user_id") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]interface{}) - for fieldName, fieldBuf := range object { - var fieldVal interface{} - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil -} - -// Override default JSON handling for UserTitle to handle AdditionalProperties -func (a UserTitle) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) - - if a.Ctime != nil { - object["ctime"], err = json.Marshal(a.Ctime) - if err != nil { - return nil, fmt.Errorf("error marshaling 'ctime': %w", err) - } - } - - if a.Rate != nil { - object["rate"], err = json.Marshal(a.Rate) - if err != nil { - return nil, fmt.Errorf("error marshaling 'rate': %w", err) - } - } - - if a.ReviewId != nil { - object["review_id"], err = json.Marshal(a.ReviewId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'review_id': %w", err) - } - } - - object["status"], err = json.Marshal(a.Status) - if err != nil { - return nil, fmt.Errorf("error marshaling 'status': %w", err) - } - - object["title_id"], err = json.Marshal(a.TitleId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'title_id': %w", err) - } - - object["user_id"], err = json.Marshal(a.UserId) - if err != nil { - return nil, fmt.Errorf("error marshaling 'user_id': %w", err) - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} +// UpdateUserTitleJSONRequestBody defines body for UpdateUserTitle for application/json ContentType. +type UpdateUserTitleJSONRequestBody UpdateUserTitleJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { @@ -578,13 +270,28 @@ type ServerInterface interface { GetTitles(c *gin.Context, params GetTitlesParams) // Get title description // (GET /titles/{title_id}) - GetTitlesTitleId(c *gin.Context, titleId int64, params GetTitlesTitleIdParams) + GetTitle(c *gin.Context, titleId int64, params GetTitleParams) // Get user info // (GET /users/{user_id}) - GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + GetUsersId(c *gin.Context, userId string, params GetUsersIdParams) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(c *gin.Context, userId int64) // Get user titles - // (GET /users/{user_id}/titles/) - GetUsersUserIdTitles(c *gin.Context, userId string, params GetUsersUserIdTitlesParams) + // (GET /users/{user_id}/titles) + GetUserTitles(c *gin.Context, userId string, params GetUserTitlesParams) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(c *gin.Context, userId int64) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(c *gin.Context, userId int64, titleId int64) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(c *gin.Context, userId int64, titleId int64) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(c *gin.Context, userId int64, titleId int64) } // ServerInterfaceWrapper converts contexts to parameters. @@ -628,6 +335,14 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { return } + // ------------- Optional query parameter "ext_search" ------------- + + err = runtime.BindQueryParameter("form", true, false, "ext_search", c.Request.URL.Query(), ¶ms.ExtSearch) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter ext_search: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) @@ -638,7 +353,7 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { // ------------- Optional query parameter "status" ------------- - err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) return @@ -702,8 +417,8 @@ func (siw *ServerInterfaceWrapper) GetTitles(c *gin.Context) { siw.Handler.GetTitles(c, params) } -// GetTitlesTitleId operation middleware -func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { var err error @@ -717,7 +432,7 @@ func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetTitlesTitleIdParams + var params GetTitleParams // ------------- Optional query parameter "fields" ------------- @@ -734,11 +449,11 @@ func (siw *ServerInterfaceWrapper) GetTitlesTitleId(c *gin.Context) { } } - siw.Handler.GetTitlesTitleId(c, titleId, params) + siw.Handler.GetTitle(c, titleId, params) } -// GetUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { +// GetUsersId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersId(c *gin.Context) { var err error @@ -752,7 +467,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdParams + var params GetUsersIdParams // ------------- Optional query parameter "fields" ------------- @@ -769,11 +484,35 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { } } - siw.Handler.GetUsersUserId(c, userId, params) + siw.Handler.GetUsersId(c, userId, params) } -// GetUsersUserIdTitles operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { +// UpdateUser operation middleware +func (siw *ServerInterfaceWrapper) UpdateUser(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUser(c, userId) +} + +// GetUserTitles operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitles(c *gin.Context) { var err error @@ -787,7 +526,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdTitlesParams + var params GetUserTitlesParams // ------------- Optional query parameter "cursor" ------------- @@ -797,6 +536,22 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", c.Request.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "sort_forward" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort_forward", c.Request.URL.Query(), ¶ms.SortForward) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter sort_forward: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "word" ------------- err = runtime.BindQueryParameter("form", true, false, "word", c.Request.URL.Query(), ¶ms.Word) @@ -807,7 +562,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { // ------------- Optional query parameter "status" ------------- - err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + err = runtime.BindQueryParameter("form", false, false, "status", c.Request.URL.Query(), ¶ms.Status) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) return @@ -815,7 +570,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { // ------------- Optional query parameter "watch_status" ------------- - err = runtime.BindQueryParameter("form", true, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) + err = runtime.BindQueryParameter("form", false, false, "watch_status", c.Request.URL.Query(), ¶ms.WatchStatus) if err != nil { siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter watch_status: %w", err), http.StatusBadRequest) return @@ -829,6 +584,14 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { return } + // ------------- Optional query parameter "my_rate" ------------- + + err = runtime.BindQueryParameter("form", true, false, "my_rate", c.Request.URL.Query(), ¶ms.MyRate) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter my_rate: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "release_year" ------------- err = runtime.BindQueryParameter("form", true, false, "release_year", c.Request.URL.Query(), ¶ms.ReleaseYear) @@ -868,7 +631,130 @@ func (siw *ServerInterfaceWrapper) GetUsersUserIdTitles(c *gin.Context) { } } - siw.Handler.GetUsersUserIdTitles(c, userId, params) + siw.Handler.GetUserTitles(c, userId, params) +} + +// AddUserTitle operation middleware +func (siw *ServerInterfaceWrapper) AddUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.AddUserTitle(c, userId) +} + +// DeleteUserTitle operation middleware +func (siw *ServerInterfaceWrapper) DeleteUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteUserTitle(c, userId, titleId) +} + +// GetUserTitle operation middleware +func (siw *ServerInterfaceWrapper) GetUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetUserTitle(c, userId, titleId) +} + +// UpdateUserTitle operation middleware +func (siw *ServerInterfaceWrapper) UpdateUserTitle(c *gin.Context) { + + var err error + + // ------------- Path parameter "user_id" ------------- + var userId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "title_id" ------------- + var titleId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "title_id", c.Param("title_id"), &titleId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter title_id: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpdateUserTitle(c, userId, titleId) } // GinServerOptions provides options for the Gin server. @@ -899,9 +785,14 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options } router.GET(options.BaseURL+"/titles", wrapper.GetTitles) - router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitlesTitleId) - router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) - router.GET(options.BaseURL+"/users/:user_id/titles/", wrapper.GetUsersUserIdTitles) + router.GET(options.BaseURL+"/titles/:title_id", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersId) + router.PATCH(options.BaseURL+"/users/:user_id", wrapper.UpdateUser) + router.GET(options.BaseURL+"/users/:user_id/titles", wrapper.GetUserTitles) + router.POST(options.BaseURL+"/users/:user_id/titles", wrapper.AddUserTitle) + router.DELETE(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.DeleteUserTitle) + router.GET(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.GetUserTitle) + router.PATCH(options.BaseURL+"/users/:user_id/titles/:title_id", wrapper.UpdateUserTitle) } type GetTitlesRequestObject struct { @@ -950,136 +841,445 @@ func (response GetTitles500Response) VisitGetTitlesResponse(w http.ResponseWrite return nil } -type GetTitlesTitleIdRequestObject struct { +type GetTitleRequestObject struct { TitleId int64 `json:"title_id"` - Params GetTitlesTitleIdParams + Params GetTitleParams } -type GetTitlesTitleIdResponseObject interface { - VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error +type GetTitleResponseObject interface { + VisitGetTitleResponse(w http.ResponseWriter) error } -type GetTitlesTitleId200JSONResponse Title +type GetTitle200JSONResponse Title -func (response GetTitlesTitleId200JSONResponse) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitlesTitleId204Response struct { +type GetTitle204Response struct { } -func (response GetTitlesTitleId204Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetTitlesTitleId400Response struct { +type GetTitle400Response struct { } -func (response GetTitlesTitleId400Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle400Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetTitlesTitleId404Response struct { +type GetTitle404Response struct { } -func (response GetTitlesTitleId404Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle404Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetTitlesTitleId500Response struct { +type GetTitle500Response struct { } -func (response GetTitlesTitleId500Response) VisitGetTitlesTitleIdResponse(w http.ResponseWriter) error { +func (response GetTitle500Response) VisitGetTitleResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } -type GetUsersUserIdRequestObject struct { +type GetUsersIdRequestObject struct { UserId string `json:"user_id"` - Params GetUsersUserIdParams + Params GetUsersIdParams } -type GetUsersUserIdResponseObject interface { - VisitGetUsersUserIdResponse(w http.ResponseWriter) error +type GetUsersIdResponseObject interface { + VisitGetUsersIdResponse(w http.ResponseWriter) error } -type GetUsersUserId200JSONResponse User +type GetUsersId200JSONResponse User -func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId200JSONResponse) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserId400Response struct { +type GetUsersId400Response struct { } -func (response GetUsersUserId400Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId400Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetUsersUserId404Response struct { +type GetUsersId404Response struct { } -func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId404Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(404) return nil } -type GetUsersUserId500Response struct { +type GetUsersId500Response struct { } -func (response GetUsersUserId500Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { +func (response GetUsersId500Response) VisitGetUsersIdResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } -type GetUsersUserIdTitlesRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdTitlesParams +type UpdateUserRequestObject struct { + UserId int64 `json:"user_id"` + Body *UpdateUserJSONRequestBody } -type GetUsersUserIdTitlesResponseObject interface { - VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error +type UpdateUserResponseObject interface { + VisitUpdateUserResponse(w http.ResponseWriter) error } -type GetUsersUserIdTitles200JSONResponse []UserTitle +type UpdateUser200JSONResponse User -func (response GetUsersUserIdTitles200JSONResponse) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response UpdateUser200JSONResponse) VisitUpdateUserResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserIdTitles204Response struct { +type UpdateUser400Response struct { } -func (response GetUsersUserIdTitles204Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response UpdateUser400Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUser401Response struct { +} + +func (response UpdateUser401Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUser403Response struct { +} + +func (response UpdateUser403Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUser404Response struct { +} + +func (response UpdateUser404Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUser409Response struct { +} + +func (response UpdateUser409Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type UpdateUser422Response struct { +} + +func (response UpdateUser422Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(422) + return nil +} + +type UpdateUser500Response struct { +} + +func (response UpdateUser500Response) VisitUpdateUserResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUserTitlesRequestObject struct { + UserId string `json:"user_id"` + Params GetUserTitlesParams +} + +type GetUserTitlesResponseObject interface { + VisitGetUserTitlesResponse(w http.ResponseWriter) error +} + +type GetUserTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + Data []UserTitle `json:"data"` +} + +func (response GetUserTitles200JSONResponse) VisitGetUserTitlesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserTitles204Response struct { +} + +func (response GetUserTitles204Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type GetUsersUserIdTitles400Response struct { +type GetUserTitles400Response struct { } -func (response GetUsersUserIdTitles400Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles400Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { w.WriteHeader(400) return nil } -type GetUsersUserIdTitles500Response struct { +type GetUserTitles404Response struct { } -func (response GetUsersUserIdTitles500Response) VisitGetUsersUserIdTitlesResponse(w http.ResponseWriter) error { +func (response GetUserTitles404Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetUserTitles500Response struct { +} + +func (response GetUserTitles500Response) VisitGetUserTitlesResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type AddUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + Body *AddUserTitleJSONRequestBody +} + +type AddUserTitleResponseObject interface { + VisitAddUserTitleResponse(w http.ResponseWriter) error +} + +type AddUserTitle200JSONResponse UserTitleMini + +func (response AddUserTitle200JSONResponse) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type AddUserTitle400Response struct { +} + +func (response AddUserTitle400Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type AddUserTitle401Response struct { +} + +func (response AddUserTitle401Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type AddUserTitle403Response struct { +} + +func (response AddUserTitle403Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type AddUserTitle404Response struct { +} + +func (response AddUserTitle404Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type AddUserTitle409Response struct { +} + +func (response AddUserTitle409Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(409) + return nil +} + +type AddUserTitle500Response struct { +} + +func (response AddUserTitle500Response) VisitAddUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type DeleteUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type DeleteUserTitleResponseObject interface { + VisitDeleteUserTitleResponse(w http.ResponseWriter) error +} + +type DeleteUserTitle200Response struct { +} + +func (response DeleteUserTitle200Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteUserTitle401Response struct { +} + +func (response DeleteUserTitle401Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteUserTitle403Response struct { +} + +func (response DeleteUserTitle403Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type DeleteUserTitle404Response struct { +} + +func (response DeleteUserTitle404Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type DeleteUserTitle500Response struct { +} + +func (response DeleteUserTitle500Response) VisitDeleteUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type GetUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` +} + +type GetUserTitleResponseObject interface { + VisitGetUserTitleResponse(w http.ResponseWriter) error +} + +type GetUserTitle200JSONResponse UserTitleMini + +func (response GetUserTitle200JSONResponse) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserTitle204Response struct { +} + +func (response GetUserTitle204Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type GetUserTitle400Response struct { +} + +func (response GetUserTitle400Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetUserTitle404Response struct { +} + +func (response GetUserTitle404Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetUserTitle500Response struct { +} + +func (response GetUserTitle500Response) VisitGetUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type UpdateUserTitleRequestObject struct { + UserId int64 `json:"user_id"` + TitleId int64 `json:"title_id"` + Body *UpdateUserTitleJSONRequestBody +} + +type UpdateUserTitleResponseObject interface { + VisitUpdateUserTitleResponse(w http.ResponseWriter) error +} + +type UpdateUserTitle200JSONResponse UserTitleMini + +func (response UpdateUserTitle200JSONResponse) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateUserTitle400Response struct { +} + +func (response UpdateUserTitle400Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateUserTitle401Response struct { +} + +func (response UpdateUserTitle401Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateUserTitle403Response struct { +} + +func (response UpdateUserTitle403Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(403) + return nil +} + +type UpdateUserTitle404Response struct { +} + +func (response UpdateUserTitle404Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateUserTitle500Response struct { +} + +func (response UpdateUserTitle500Response) VisitUpdateUserTitleResponse(w http.ResponseWriter) error { w.WriteHeader(500) return nil } @@ -1091,13 +1291,28 @@ type StrictServerInterface interface { GetTitles(ctx context.Context, request GetTitlesRequestObject) (GetTitlesResponseObject, error) // Get title description // (GET /titles/{title_id}) - GetTitlesTitleId(ctx context.Context, request GetTitlesTitleIdRequestObject) (GetTitlesTitleIdResponseObject, error) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) // Get user info // (GET /users/{user_id}) - GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + GetUsersId(ctx context.Context, request GetUsersIdRequestObject) (GetUsersIdResponseObject, error) + // Partially update a user account + // (PATCH /users/{user_id}) + UpdateUser(ctx context.Context, request UpdateUserRequestObject) (UpdateUserResponseObject, error) // Get user titles - // (GET /users/{user_id}/titles/) - GetUsersUserIdTitles(ctx context.Context, request GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) + // (GET /users/{user_id}/titles) + GetUserTitles(ctx context.Context, request GetUserTitlesRequestObject) (GetUserTitlesResponseObject, error) + // Add a title to a user + // (POST /users/{user_id}/titles) + AddUserTitle(ctx context.Context, request AddUserTitleRequestObject) (AddUserTitleResponseObject, error) + // Delete a usertitle + // (DELETE /users/{user_id}/titles/{title_id}) + DeleteUserTitle(ctx context.Context, request DeleteUserTitleRequestObject) (DeleteUserTitleResponseObject, error) + // Get user title + // (GET /users/{user_id}/titles/{title_id}) + GetUserTitle(ctx context.Context, request GetUserTitleRequestObject) (GetUserTitleResponseObject, error) + // Update a usertitle + // (PATCH /users/{user_id}/titles/{title_id}) + UpdateUserTitle(ctx context.Context, request UpdateUserTitleRequestObject) (UpdateUserTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -1139,18 +1354,18 @@ func (sh *strictHandler) GetTitles(ctx *gin.Context, params GetTitlesParams) { } } -// GetTitlesTitleId operation middleware -func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, params GetTitlesTitleIdParams) { - var request GetTitlesTitleIdRequestObject +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, titleId int64, params GetTitleParams) { + var request GetTitleRequestObject request.TitleId = titleId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitlesTitleId(ctx, request.(GetTitlesTitleIdRequestObject)) + return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitlesTitleId") + handler = middleware(handler, "GetTitle") } response, err := handler(ctx, request) @@ -1158,8 +1373,8 @@ func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, param if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitlesTitleIdResponseObject); ok { - if err := validResponse.VisitGetTitlesTitleIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1167,18 +1382,18 @@ func (sh *strictHandler) GetTitlesTitleId(ctx *gin.Context, titleId int64, param } } -// GetUsersUserId operation middleware -func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { - var request GetUsersUserIdRequestObject +// GetUsersId operation middleware +func (sh *strictHandler) GetUsersId(ctx *gin.Context, userId string, params GetUsersIdParams) { + var request GetUsersIdRequestObject request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) + return sh.ssi.GetUsersId(ctx, request.(GetUsersIdRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserId") + handler = middleware(handler, "GetUsersId") } response, err := handler(ctx, request) @@ -1186,8 +1401,8 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUsersIdResponseObject); ok { + if err := validResponse.VisitGetUsersIdResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { @@ -1195,18 +1410,53 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params } } -// GetUsersUserIdTitles operation middleware -func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, params GetUsersUserIdTitlesParams) { - var request GetUsersUserIdTitlesRequestObject +// UpdateUser operation middleware +func (sh *strictHandler) UpdateUser(ctx *gin.Context, userId int64) { + var request UpdateUserRequestObject + + request.UserId = userId + + var body UpdateUserJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUser(ctx, request.(UpdateUserRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUser") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserResponseObject); ok { + if err := validResponse.VisitUpdateUserResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUserTitles operation middleware +func (sh *strictHandler) GetUserTitles(ctx *gin.Context, userId string, params GetUserTitlesParams) { + var request GetUserTitlesRequestObject request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetUsersUserIdTitles(ctx, request.(GetUsersUserIdTitlesRequestObject)) + return sh.ssi.GetUserTitles(ctx, request.(GetUserTitlesRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetUsersUserIdTitles") + handler = middleware(handler, "GetUserTitles") } response, err := handler(ctx, request) @@ -1214,8 +1464,135 @@ func (sh *strictHandler) GetUsersUserIdTitles(ctx *gin.Context, userId string, p if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdTitlesResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdTitlesResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUserTitlesResponseObject); ok { + if err := validResponse.VisitGetUserTitlesResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// AddUserTitle operation middleware +func (sh *strictHandler) AddUserTitle(ctx *gin.Context, userId int64) { + var request AddUserTitleRequestObject + + request.UserId = userId + + var body AddUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.AddUserTitle(ctx, request.(AddUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "AddUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(AddUserTitleResponseObject); ok { + if err := validResponse.VisitAddUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteUserTitle operation middleware +func (sh *strictHandler) DeleteUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request DeleteUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteUserTitle(ctx, request.(DeleteUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(DeleteUserTitleResponseObject); ok { + if err := validResponse.VisitDeleteUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetUserTitle operation middleware +func (sh *strictHandler) GetUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request GetUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUserTitle(ctx, request.(GetUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(GetUserTitleResponseObject); ok { + if err := validResponse.VisitGetUserTitleResponse(ctx.Writer); err != nil { + ctx.Error(err) + } + } else if response != nil { + ctx.Error(fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateUserTitle operation middleware +func (sh *strictHandler) UpdateUserTitle(ctx *gin.Context, userId int64, titleId int64) { + var request UpdateUserTitleRequestObject + + request.UserId = userId + request.TitleId = titleId + + var body UpdateUserTitleJSONRequestBody + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.Status(http.StatusBadRequest) + ctx.Error(err) + return + } + request.Body = &body + + handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateUserTitle(ctx, request.(UpdateUserTitleRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateUserTitle") + } + + response, err := handler(ctx, request) + + if err != nil { + ctx.Error(err) + ctx.Status(http.StatusInternalServerError) + } else if validResponse, ok := response.(UpdateUserTitleResponseObject); ok { + if err := validResponse.VisitUpdateUserTitleResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index 281fe82..08a4d54 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -13,8 +13,11 @@ paths: $ref: "./paths/titles-id.yaml" /users/{user_id}: $ref: "./paths/users-id.yaml" - /users/{user_id}/titles/: + /users/{user_id}/titles: $ref: "./paths/users-id-titles.yaml" + /users/{user_id}/titles/{title_id}: + $ref: "./paths/users-id-titles-id.yaml" + components: parameters: $ref: "./parameters/_index.yaml" diff --git a/api/paths/titles-id.yaml b/api/paths/titles-id.yaml index 01fa504..235743f 100644 --- a/api/paths/titles-id.yaml +++ b/api/paths/titles-id.yaml @@ -1,5 +1,6 @@ get: summary: Get title description + operationId: getTitle parameters: - in: path name: title_id diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index e868ed6..4288417 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -8,6 +8,11 @@ get: schema: type: boolean default: true + - in: query + name: ext_search + schema: + type: boolean + default: false - in: query name: word schema: @@ -15,7 +20,12 @@ get: - in: query name: status schema: - $ref: '../schemas/enums/TitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: false - in: query name: rating schema: diff --git a/api/paths/users-id-titles-id.yaml b/api/paths/users-id-titles-id.yaml new file mode 100644 index 0000000..b4ad884 --- /dev/null +++ b/api/paths/users-id-titles-id.yaml @@ -0,0 +1,107 @@ +get: + summary: Get user title + operationId: getUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: User titles + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '204': + description: No user title found + '400': + description: Request params are not correct + '404': + description: User or title not found + '500': + description: Unknown server error + +patch: + summary: Update a usertitle + description: User updating title list of watched + operationId: updateUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + $ref: '../schemas/enums/UserTitleStatus.yaml' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully updated + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to update title + '404': + description: User or Title not found + '500': + description: Internal server error + +delete: + summary: Delete a usertitle + description: User deleting title from list of watched + operationId: deleteUserTitle + parameters: + - in: path + name: user_id + required: true + schema: + type: integer + format: int64 + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Title successfully deleted + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to delete title + '404': + description: User or Title not found + '500': + description: Internal server error \ No newline at end of file diff --git a/api/paths/users-id-titles.yaml b/api/paths/users-id-titles.yaml index 0cde5af..75f5461 100644 --- a/api/paths/users-id-titles.yaml +++ b/api/paths/users-id-titles.yaml @@ -1,12 +1,19 @@ get: summary: Get user titles + operationId: getUserTitles parameters: - $ref: '../parameters/cursor.yaml' + - $ref: "../parameters/title_sort.yaml" - in: path name: user_id required: true schema: type: string + - in: query + name: sort_forward + schema: + type: boolean + default: true - in: query name: word schema: @@ -14,16 +21,30 @@ get: - in: query name: status schema: - $ref: '../schemas/enums/TitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/TitleStatus.yaml' + description: List of title statuses to filter + style: form + explode: false - in: query name: watch_status schema: - $ref: '../schemas/enums/UserTitleStatus.yaml' + type: array + items: + $ref: '../schemas/enums/UserTitleStatus.yaml' + style: form + explode: false - in: query name: rating schema: type: number format: double + - in: query + name: my_rate + schema: + type: integer + format: int32 - in: query name: release_year schema: @@ -50,12 +71,73 @@ get: content: application/json: schema: - type: array - items: - $ref: '../schemas/UserTitle.yaml' + type: object + properties: + data: + type: array + items: + $ref: '../schemas/UserTitle.yaml' + cursor: + $ref: '../schemas/CursorObj.yaml' + required: + - data + - cursor '204': description: No titles found '400': description: Request params are not correct + '404': + description: User not found '500': description: Unknown server error + +post: + summary: Add a title to a user + description: User adding title to list af watched, status required + operationId: addUserTitle + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: ID of the user to assign the title to + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - title_id + - status + properties: + title_id: + type: integer + format: int64 + status: + $ref: '../schemas/enums/UserTitleStatus.yaml' + rate: + type: integer + format: int32 + responses: + '200': + description: Title successfully added to user + content: + application/json: + schema: + $ref: '../schemas/UserTitleMini.yaml' + '400': + description: Invalid request body (missing fields, invalid types, etc.) + '401': + description: Unauthorized — missing or invalid auth token + '403': + description: Forbidden — user not allowed to assign titles to this user + '404': + description: User or Title not found + '409': + description: Conflict — title already assigned to user (if applicable) + '500': + description: Internal server error \ No newline at end of file diff --git a/api/paths/users-id.yaml b/api/paths/users-id.yaml index 0acdb81..fe62e46 100644 --- a/api/paths/users-id.yaml +++ b/api/paths/users-id.yaml @@ -1,5 +1,6 @@ get: summary: Get user info + operationId: getUsersId parameters: - in: path name: user_id @@ -24,3 +25,79 @@ get: description: Request params are not correct '500': description: Unknown server error + +patch: + summary: Partially update a user account + description: | + Update selected user profile fields (excluding password). + Password updates must be done via the dedicated auth-service (`/auth/`). + Fields not provided in the request body remain unchanged. + operationId: updateUser + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + format: int64 + description: User ID (primary key) + example: 123 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + nickname: + type: string + pattern: '^[a-zA-Z0-9_-]{3,16}$' + description: Username (alphanumeric + `_` or `-`, 3–16 chars) + maxLength: 16 + minLength: 3 + example: john_doe_43 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. + additionalProperties: false + description: Only provided fields are updated. Omitted fields remain unchanged. + responses: + '200': + description: User updated successfully. Returns updated user representation (excluding sensitive fields). + content: + application/json: + schema: + $ref: '../schemas/User.yaml' + '400': + description: Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON) + '401': + description: Unauthorized — missing or invalid authentication token + '403': + description: Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights) + '404': + description: User not found + '409': + description: Conflict — e.g., requested `nickname` or `mail` already taken by another user + '422': + description: Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`) + '500': + description: Unknown server error diff --git a/api/schemas/Image.yaml b/api/schemas/Image.yaml index 7226b29..3fb520b 100644 --- a/api/schemas/Image.yaml +++ b/api/schemas/Image.yaml @@ -1,9 +1,10 @@ type: object properties: - id: +# id выпиливаем + id: type: integer format: int64 storage_type: - type: string + $ref: './enums/StorageType.yaml' image_path: type: string diff --git a/api/schemas/Studio.yaml b/api/schemas/Studio.yaml index 35b40a8..26a2adf 100644 --- a/api/schemas/Studio.yaml +++ b/api/schemas/Studio.yaml @@ -3,6 +3,7 @@ required: - id - name properties: +# id не нужен id: type: integer format: int64 diff --git a/api/schemas/Title.yaml b/api/schemas/Title.yaml index 7497d1f..877ee24 100644 --- a/api/schemas/Title.yaml +++ b/api/schemas/Title.yaml @@ -60,4 +60,3 @@ properties: additionalProperties: type: number format: double -additionalProperties: true diff --git a/api/schemas/User.yaml b/api/schemas/User.yaml index 8b4d88d..4e53534 100644 --- a/api/schemas/User.yaml +++ b/api/schemas/User.yaml @@ -5,11 +5,8 @@ properties: format: int64 description: Unique user ID (primary key) example: 1 - avatar_id: - type: integer - format: int64 - description: ID of the user avatar (references images table) - example: null + image: + $ref: '../schemas/Image.yaml' mail: type: string format: email diff --git a/api/schemas/UserTitle.yaml b/api/schemas/UserTitle.yaml index 658e350..ef619cb 100644 --- a/api/schemas/UserTitle.yaml +++ b/api/schemas/UserTitle.yaml @@ -7,9 +7,8 @@ properties: user_id: type: integer format: int64 - title_id: - type: integer - format: int64 + title: + $ref: ./Title.yaml status: $ref: ./enums/UserTitleStatus.yaml rate: @@ -21,4 +20,3 @@ properties: ctime: type: string format: date-time -additionalProperties: true diff --git a/api/schemas/UserTitleMini.yaml b/api/schemas/UserTitleMini.yaml new file mode 100644 index 0000000..e1a5a74 --- /dev/null +++ b/api/schemas/UserTitleMini.yaml @@ -0,0 +1,23 @@ +type: object +required: + - user_id + - title_id + - status +properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: ./enums/UserTitleStatus.yaml + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time diff --git a/api/schemas/_index.yaml b/api/schemas/_index.yaml index ac49f37..d893ced 100644 --- a/api/schemas/_index.yaml +++ b/api/schemas/_index.yaml @@ -1,5 +1,5 @@ CursorObj: - $ref: ./CursorObj.yaml + $ref: "./CursorObj.yaml" TitleSort: $ref: "./TitleSort.yaml" Image: diff --git a/api/schemas/enums/StorageType.yaml b/api/schemas/enums/StorageType.yaml new file mode 100644 index 0000000..1984a87 --- /dev/null +++ b/api/schemas/enums/StorageType.yaml @@ -0,0 +1,5 @@ +type: string +description: Image storage type +enum: + - s3 + - local \ No newline at end of file diff --git a/api/schemas/updateUser.yaml b/api/schemas/updateUser.yaml new file mode 100644 index 0000000..e1d77af --- /dev/null +++ b/api/schemas/updateUser.yaml @@ -0,0 +1,26 @@ +type: object +properties: + avatar_id: + type: integer + format: int64 + nullable: true + description: ID of the user avatar (references `images.id`); set to `null` to remove avatar + example: 42 + mail: + type: string + format: email + pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$' + description: User email (must be unique and valid) + example: john.doe.updated@example.com + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Smith + user_desc: + type: string + description: User description / bio + maxLength: 512 + example: Just a curious developer. +additionalProperties: false +description: Only provided fields are updated. Omitted fields remain unchanged. \ No newline at end of file diff --git a/auth/auth.gen.go b/auth/auth.gen.go index adb2b06..7276545 100644 --- a/auth/auth.gen.go +++ b/auth/auth.gen.go @@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface { } type PostAuthSignIn200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` + UserName string `json:"user_name"` } func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { @@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface { } type PostAuthSignUp200JSONResponse struct { - Error *string `json:"error"` - Success *bool `json:"success,omitempty"` - UserId *string `json:"user_id"` + UserId int64 `json:"user_id"` } func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { diff --git a/auth/openapi-auth.yaml b/auth/openapi-auth.yaml index 913c000..239b03b 100644 --- a/auth/openapi-auth.yaml +++ b/auth/openapi-auth.yaml @@ -30,16 +30,13 @@ paths: content: application/json: schema: + required: + - user_id type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: - type: string - nullable: true + type: integer + format: int64 /auth/sign-in: post: @@ -59,29 +56,22 @@ paths: type: string format: password responses: + # This one also sets two cookies: access_token and refresh_token "200": description: Sign-in result with JWT - # headers: - # Set-Cookie: - # schema: - # type: array - # items: - # type: string - # explode: true - # style: simple content: application/json: schema: + required: + - user_id + - user_name type: object properties: - success: - type: boolean - error: - type: string - nullable: true user_id: + type: integer + format: int64 + user_name: type: string - nullable: true "401": description: Access denied due to invalid credentials content: diff --git a/deploy/api_gen.ps1 b/deploy/api_gen.ps1 new file mode 100644 index 0000000..c8966b7 --- /dev/null +++ b/deploy/api_gen.ps1 @@ -0,0 +1,4 @@ +cd ./api +openapi-format .\openapi.yaml --output .\_build\openapi.yaml --yaml +cd .. +oapi-codegen --config=api\oapi-codegen.yaml api\_build\openapi.yaml diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1a96253..79ad2f5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -11,20 +11,34 @@ services: - "${POSTGRES_PORT}:5432" volumes: - postgres_data:/var/lib/postgresql + networks: + - nyanimedb-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s - # pgadmin: - # image: dpage/pgadmin4:${PGADMIN_VERSION} - # container_name: pgadmin - # restart: always - # environment: - # PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} - # PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} - # ports: - # - "${PGADMIN_PORT}:80" - # depends_on: - # - postgres - # volumes: - # - pgadmin_data:/var/lib/pgadmin + rabbitmq: + image: rabbitmq:3-management + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - nyanimedb-network + healthcheck: + test: ["CMD", "rabbitmqctl", "status"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s nyanimedb-backend: image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest @@ -37,6 +51,23 @@ services: - "8080:8080" depends_on: - postgres + - rabbitmq + networks: + - nyanimedb-network + + nyanimedb-auth: + image: meowgit.nekoea.red/nihonium/nyanimedb-auth:latest + container_name: nyanimedb-auth + restart: always + environment: + LOG_LEVEL: ${LOG_LEVEL} + DATABASE_URL: ${DATABASE_URL} + ports: + - "8082:8082" + depends_on: + - postgres + networks: + - nyanimedb-network nyanimedb-frontend: image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest @@ -46,7 +77,12 @@ services: - "8081:80" depends_on: - nyanimedb-backend + networks: + - nyanimedb-network volumes: postgres_data: - pgadmin_data: + rabbitmq_data: + +networks: + nyanimedb-network: diff --git a/deploy/generate.sh b/deploy/generate.sh index d7d94a2..29587cf 100644 --- a/deploy/generate.sh +++ b/deploy/generate.sh @@ -1,3 +1,3 @@ -npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios +npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios --useUnionTypes oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml sqlc generate -f .\sql\sqlc.yaml \ No newline at end of file diff --git a/go.mod b/go.mod index bf73121..6662bc1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nyanimedb go 1.25.0 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.0 @@ -36,6 +37,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rabbitmq/amqp091-go v1.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/go.sum b/go.sum index 8f46514..520a22b 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -70,9 +72,13 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/streadway/amqp v1.1.0 h1:py12iX8XSyI7aN/3dUT8DFIDJazNJsVJdxNVEpnQTZM= +github.com/streadway/amqp v1.1.0/go.mod h1:WYSrTEYHOXHd0nwFeUXAe2G2hRnQT+deZJJf88uS9Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -87,26 +93,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/auth/handlers/handlers.go b/modules/auth/handlers/handlers.go index 9b9b0d3..261826c 100644 --- a/modules/auth/handlers/handlers.go +++ b/modules/auth/handlers/handlers.go @@ -3,22 +3,21 @@ package handlers import ( "context" "fmt" - "log" "net/http" auth "nyanimedb/auth" sqlc "nyanimedb/sql" "strconv" "time" + "github.com/alexedwards/argon2id" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" ) var accessSecret = []byte("my_access_secret_key") var refreshSecret = []byte("my_refresh_secret_key") -var UserDb = make(map[string]string) // TEMP: stores passwords - type Server struct { db *sqlc.Queries } @@ -32,6 +31,22 @@ func parseInt64(s string) (int32, error) { return int32(i), err } +func HashPassword(password string) (string, error) { + params := &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + return argon2id.CreateHash(password, params) +} + +func CheckPassword(password, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + func generateTokens(userID string) (accessToken string, refreshToken string, err error) { accessClaims := jwt.MapClaims{ "user_id": userID, @@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err } func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { - err := "" - success := true - UserDb[req.Body.Nickname] = req.Body.Pass + passhash, err := HashPassword(req.Body.Pass) + if err != nil { + log.Errorf("failed to hash password: %v", err) + // TODO: return 500 + } + + user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{ + Passhash: passhash, + Nickname: req.Body.Nickname, + }) + if err != nil { + log.Errorf("failed to create user %s: %v", req.Body.Nickname, err) + // TODO: check err and retyrn 400/500 + } return auth.PostAuthSignUp200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user_id, }, nil } func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { - // ctx.SetCookie("122") ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) if !ok { log.Print("failed to get gin context") @@ -77,28 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") } - err := "" - success := true + user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) + if err != nil { + log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err) + // TODO: return 400/500 + } - pass, ok := UserDb[req.Body.Nickname] - if !ok || pass != req.Body.Pass { - e := "invalid credentials" + ok, err = CheckPassword(req.Body.Pass, user.Passhash) + if err != nil { + log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + if !ok { + err_msg := "invalid credentials" return auth.PostAuthSignIn401JSONResponse{ - Error: &e, + Error: &err_msg, }, nil } - accessToken, refreshToken, _ := generateTokens(req.Body.Nickname) + accessToken, refreshToken, err := generateTokens(req.Body.Nickname) + if err != nil { + log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err) + // TODO: return 500 + } + // TODO: check cookie settings carefully ginCtx.SetSameSite(http.SameSiteStrictMode) - ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true) - ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true) + ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) + ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) - // Return access token; refresh token can be returned in response or HttpOnly cookie result := auth.PostAuthSignIn200JSONResponse{ - Error: &err, - Success: &success, - UserId: &req.Body.Nickname, + UserId: user.ID, + UserName: user.Nickname, } return result, nil } diff --git a/modules/auth/main.go b/modules/auth/main.go index c001e8b..7554f42 100644 --- a/modules/auth/main.go +++ b/modules/auth/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "fmt" + "os" "time" auth "nyanimedb/auth" @@ -9,14 +12,22 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" ) var AppConfig Config func main() { + // TODO: env args r := gin.Default() - var queries *sqlc.Queries = nil + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) + os.Exit(1) + } + + var queries *sqlc.Queries = sqlc.New(pool) server := handlers.NewServer(queries) diff --git a/modules/auth/queries.sql b/modules/auth/queries.sql new file mode 100644 index 0000000..828d2af --- /dev/null +++ b/modules/auth/queries.sql @@ -0,0 +1,11 @@ +-- name: GetUserByNickname :one +SELECT * +FROM users +WHERE nickname = sqlc.arg('nickname'); + +-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES (sqlc.arg(passhash), sqlc.arg(nickname)) +RETURNING id; + diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index b85d022..cad4f0f 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -1,19 +1,165 @@ package handlers import ( + "encoding/json" + "fmt" + oapi "nyanimedb/api" + "nyanimedb/modules/backend/rmq" sqlc "nyanimedb/sql" "strconv" ) +type Handler struct { + publisher *rmq.Publisher +} + +func New(publisher *rmq.Publisher) *Handler { + return &Handler{publisher: publisher} +} + type Server struct { - db *sqlc.Queries + db *sqlc.Queries + publisher *rmq.Publisher + RPCclient *rmq.RPCClient } -func NewServer(db *sqlc.Queries) Server { - return Server{db: db} +func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { + return &Server{ + db: db, + publisher: publisher, + RPCclient: rpcclient, + } } -func parseInt64(s string) (int32, error) { +func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) { + if s == nil { + return nil, nil + } + var t oapi.StorageType + switch *s { + case sqlc.StorageTypeTLocal: + t = oapi.Local + case sqlc.StorageTypeTS3: + t = oapi.S3 + default: + return nil, fmt.Errorf("unexpected storage type: %s", *s) + } + return &t, nil +} + +func (s Server) mapTitle(title sqlc.GetTitleByIDRow) (oapi.Title, error) { + + oapi_title := oapi.Title{ + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + // EpisodesLen: &episodes_lens, + Id: title.ID, + // Poster: &oapi_image, + Rating: title.Rating, + RatingCount: title.RatingCount, + // ReleaseSeason: &release_season, + ReleaseYear: title.ReleaseYear, + // Studio: &oapi_studio, + // Tags: oapi_tag_names, + // TitleNames: title_names, + // TitleStatus: oapi_status, + // AdditionalProperties: + } + + title_names := make(map[string][]string, 0) + err := json.Unmarshal(title.TitleNames, &title_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err) + } + oapi_title.TitleNames = title_names + + if len(title.EpisodesLen) > 0 { + episodes_lens := make(map[string]float64, 0) + err = json.Unmarshal(title.EpisodesLen, &episodes_lens) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err) + } + oapi_title.EpisodesLen = &episodes_lens + } + + oapi_tag_names := make(oapi.Tags, 0) + err = json.Unmarshal(title.TagNames, &oapi_tag_names) + if err != nil { + return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err) + } + oapi_title.Tags = oapi_tag_names + + var oapi_studio oapi.Studio + if title.StudioName != nil { + oapi_studio.Name = *title.StudioName + } + if title.StudioID != 0 { + oapi_studio.Id = title.StudioID + oapi_studio.Description = title.StudioDesc + if title.StudioIllustID != nil { + oapi_studio.Poster = &oapi.Image{} + oapi_studio.Poster.Id = title.StudioIllustID + oapi_studio.Poster.ImagePath = title.StudioImagePath + + s, err := sql2StorageType(title.StudioStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, studio storage type: %v", err) + } + oapi_studio.Poster.StorageType = s + + } + } + oapi_title.Studio = &oapi_studio + + var oapi_image oapi.Image + + if title.PosterID != nil { + oapi_image.Id = title.PosterID + oapi_image.ImagePath = title.TitleImagePath + s, err := sql2StorageType(title.TitleStorageType) + if err != nil { + return oapi.Title{}, fmt.Errorf("mapTitle, title starage type: %v", err) + } + oapi_image.StorageType = s + } + oapi_title.Poster = &oapi_image + + var release_season oapi.ReleaseSeason + if title.ReleaseSeason != nil { + release_season = oapi.ReleaseSeason(*title.ReleaseSeason) + } + oapi_title.ReleaseSeason = &release_season + + oapi_status, err := TitleStatus2oapi(&title.TitleStatus) + if err != nil { + return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err) + } + oapi_title.TitleStatus = oapi_status + + return oapi_title, nil +} + +func parseInt64(s string) (int64, error) { i, err := strconv.ParseInt(s, 10, 64) - return int32(i), err + return i, err +} + +func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) { + var sqlc_status []sqlc.TitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.TitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTFinished) + case oapi.TitleStatusOngoing: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTOngoing) + case oapi.TitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.TitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil } diff --git a/modules/backend/handlers/cursor.go b/modules/backend/handlers/cursor.go new file mode 100644 index 0000000..e3b48b0 --- /dev/null +++ b/modules/backend/handlers/cursor.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "strconv" +) + +// ParseCursorInto parses an opaque base64 cursor and injects values into target struct. +// +// Supported sort types: +// - "id" → sets CursorID (must be *int64) +// - "year" → sets CursorID (*int64) + CursorYear (*int32) +// - "rating" → sets CursorID (*int64) + CursorRating (*float64) +// +// Target struct may have any subset of these fields (e.g. only CursorID). +// Unknown fields are ignored. Missing fields → values are dropped (safe). +// +// Returns error if cursor is invalid or inconsistent with sort_by. +func ParseCursorInto(sortBy, cursorStr string, target any) error { + if cursorStr == "" { + return nil // no cursor → nothing to do + } + + // 1. Decode cursor + payload, err := decodeCursor(cursorStr) + if err != nil { + return err + } + + // 2. Extract ID (required for all types) + id, err := extractInt64(payload, "id") + if err != nil { + return fmt.Errorf("cursor: %v", err) + } + + // 3. Get reflect value of target (must be ptr to struct) + v := reflect.ValueOf(target) + if v.Kind() != reflect.Pointer || v.IsNil() { + return fmt.Errorf("target must be non-nil pointer to struct") + } + v = v.Elem() + if v.Kind() != reflect.Struct { + return fmt.Errorf("target must be pointer to struct") + } + + // 4. Helper: set field if exists and compatible + setField := func(fieldName string, value any) { + f := v.FieldByName(fieldName) + if !f.IsValid() || !f.CanSet() { + return // field not found or unexported + } + ft := f.Type() + vv := reflect.ValueOf(value) + + // Case: field is *T, value is T → wrap in pointer + if ft.Kind() == reflect.Pointer { + elemType := ft.Elem() + if vv.Type().AssignableTo(elemType) { + ptr := reflect.New(elemType) + ptr.Elem().Set(vv) + f.Set(ptr) + } + // nil → leave as zero (nil pointer) + } else if vv.Type().AssignableTo(ft) { + f.Set(vv) + } + // else: type mismatch → silently skip (safe) + } + + // 5. Dispatch by sort type + switch sortBy { + case "id": + setField("CursorID", id) + + case "year": + setField("CursorID", id) + param, err := extractString(payload, "param") + if err != nil { + return fmt.Errorf("cursor year: %w", err) + } + year, err := strconv.Atoi(param) + if err != nil { + return fmt.Errorf("cursor year: param must be integer, got %q", param) + } + setField("CursorYear", int32(year)) // or int, depending on your schema + + case "rating": + setField("CursorID", id) + param, err := extractString(payload, "param") + if err != nil { + return fmt.Errorf("cursor rating: %w", err) + } + rating, err := strconv.ParseFloat(param, 64) + if err != nil { + return fmt.Errorf("cursor rating: param must be float, got %q", param) + } + setField("CursorRating", rating) + + default: + return fmt.Errorf("unsupported sort_by: %q", sortBy) + } + + return nil +} + +// --- helpers --- +func decodeCursor(cursorStr string) (map[string]any, error) { + data, err := base64.RawURLEncoding.DecodeString(cursorStr) + if err != nil { + data, err = base64.StdEncoding.DecodeString(cursorStr) + if err != nil { + return nil, fmt.Errorf("invalid base64 cursor") + } + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("invalid cursor JSON: %w", err) + } + return m, nil +} + +func extractInt64(m map[string]any, key string) (int64, error) { + v, ok := m[key] + if !ok { + return 0, fmt.Errorf("missing %q", key) + } + switch x := v.(type) { + case float64: + if x == float64(int64(x)) { + return int64(x), nil + } + case string: + i, err := strconv.ParseInt(x, 10, 64) + if err == nil { + return i, nil + } + case int64, int, int32: + return reflect.ValueOf(x).Int(), nil + } + return 0, fmt.Errorf("%q must be integer", key) +} + +func extractString(m map[string]any, key string) (string, error) { + v, ok := m[key] + if !ok { + return "", fmt.Errorf("missing %q", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%q must be string", key) + } + return s, nil +} diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index f187cc4..300cc87 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -5,7 +5,10 @@ import ( "encoding/json" "fmt" oapi "nyanimedb/api" + "nyanimedb/modules/backend/rmq" sqlc "nyanimedb/sql" + "strconv" + "time" "github.com/jackc/pgx/v5" log "github.com/sirupsen/logrus" @@ -19,18 +22,18 @@ func Word2Sqlc(s *string) *string { return s } -func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) { +func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { if s == nil { return nil, nil } - var t sqlc.TitleStatusT + var t oapi.TitleStatus switch *s { - case oapi.TitleStatusFinished: - t = sqlc.TitleStatusTFinished - case oapi.TitleStatusOngoing: - t = sqlc.TitleStatusTOngoing - case oapi.TitleStatusPlanned: - t = sqlc.TitleStatusTPlanned + case sqlc.TitleStatusTFinished: + t = oapi.TitleStatusFinished + case sqlc.TitleStatusTOngoing: + t = oapi.TitleStatusOngoing + case sqlc.TitleStatusTPlanned: + t = oapi.TitleStatusPlanned default: return nil, fmt.Errorf("unexpected tittle status: %s", *s) } @@ -80,170 +83,163 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro return oapi_tag_names, nil } -func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { +// func (s Server) GetImage(ctx context.Context, id int64) (*oapi.Image, error) { - var oapi_image oapi.Image +// var oapi_image oapi.Image - sqlc_image, err := s.db.GetImageByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil //todo: error reference in db - } - return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) - } +// sqlc_image, err := s.db.GetImageByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil //todo: error reference in db +// } +// return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) +// } - //can cast and dont use brain cause all this fields required in image table - oapi_image.Id = &sqlc_image.ID - oapi_image.ImagePath = &sqlc_image.ImagePath - storageTypeStr := string(sqlc_image.StorageType) - oapi_image.StorageType = &storageTypeStr +// //can cast and dont use brain cause all this fields required in image table +// oapi_image.Id = &sqlc_image.ID +// oapi_image.ImagePath = &sqlc_image.ImagePath +// storageTypeStr := string(sqlc_image.StorageType) +// oapi_image.StorageType = string(storageTypeStr) - return &oapi_image, nil -} +// return &oapi_image, nil +// } -func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { +// func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) { - var oapi_studio oapi.Studio +// var oapi_studio oapi.Studio - sqlc_studio, err := s.db.GetStudioByID(ctx, id) - if err != nil { - if err == pgx.ErrNoRows { - return nil, nil - } - return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) - } +// sqlc_studio, err := s.db.GetStudioByID(ctx, id) +// if err != nil { +// if err == pgx.ErrNoRows { +// return nil, nil +// } +// return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) +// } - oapi_studio.Id = sqlc_studio.ID - oapi_studio.Name = sqlc_studio.StudioName - oapi_studio.Description = sqlc_studio.StudioDesc +// oapi_studio.Id = sqlc_studio.ID +// oapi_studio.Name = sqlc_studio.StudioName +// oapi_studio.Description = sqlc_studio.StudioDesc - if sqlc_studio.IllustID == nil { - return &oapi_studio, nil - } - oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) - if err != nil { - return &oapi_studio, fmt.Errorf("GetImage: %v", err) - } - if oapi_illust != nil { - oapi_studio.Poster = oapi_illust - } +// if sqlc_studio.IllustID == nil { +// return &oapi_studio, nil +// } +// oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) +// if err != nil { +// return &oapi_studio, fmt.Errorf("GetImage: %v", err) +// } +// if oapi_illust != nil { +// oapi_studio.Poster = oapi_illust +// } - return &oapi_studio, nil -} +// return &oapi_studio, nil +// } -func (s Server) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) { - - var oapi_title oapi.Title - - title_names := make(map[string][]string, 1) - err := json.Unmarshal(title.TitleNames, &title_names) - if err != nil { - return oapi_title, fmt.Errorf("unmarshal TitleNames: %v", err) - } - - episodes_lens := make(map[string]float64, 1) - err = json.Unmarshal(title.EpisodesLen, &episodes_lens) - if err != nil { - return oapi_title, fmt.Errorf("unmarshal EpisodesLen: %v", err) - } - - oapi_tag_names, err := s.GetTagsByTitleId(ctx, title.ID) - if err != nil { - return oapi_title, fmt.Errorf("GetTagsByTitleId: %v", err) - } - if oapi_tag_names != nil { - oapi_title.Tags = oapi_tag_names - } - - if title.PosterID != nil { - oapi_image, err := s.GetImage(ctx, *title.PosterID) - if err != nil { - return oapi_title, fmt.Errorf("GetImage: %v", err) - } - if oapi_image != nil { - oapi_title.Poster = oapi_image - } - } - - oapi_studio, err := s.GetStudio(ctx, title.StudioID) - if err != nil { - return oapi_title, fmt.Errorf("GetStudio: %v", err) - } - if oapi_studio != nil { - oapi_title.Studio = oapi_studio - } - - if title.ReleaseSeason != nil { - rs := oapi.ReleaseSeason(*title.ReleaseSeason) - oapi_title.ReleaseSeason = &rs - } else { - oapi_title.ReleaseSeason = nil - } - - ts := oapi.TitleStatus(title.TitleStatus) - oapi_title.TitleStatus = &ts - - oapi_title.Id = title.ID - oapi_title.Rating = title.Rating - oapi_title.RatingCount = title.RatingCount - oapi_title.ReleaseYear = title.ReleaseYear - oapi_title.TitleNames = title_names - oapi_title.EpisodesAired = title.EpisodesAired - oapi_title.EpisodesAll = title.EpisodesAll - oapi_title.EpisodesLen = &episodes_lens - - return oapi_title, nil -} - -func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitleIdRequestObject) (oapi.GetTitlesTitleIdResponseObject, error) { +func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { var oapi_title oapi.Title sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetTitlesTitleId204Response{}, nil + return oapi.GetTitle204Response{}, nil } log.Errorf("%v", err) - return oapi.GetTitlesTitleId500Response{}, nil + return oapi.GetTitle500Response{}, nil } - oapi_title, err = s.mapTitle(ctx, sqlc_title) + oapi_title, err = s.mapTitle(sqlc_title) if err != nil { log.Errorf("%v", err) - return oapi.GetTitlesTitleId500Response{}, nil + return oapi.GetTitle500Response{}, nil } - return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil + return oapi.GetTitle200JSONResponse(oapi_title), nil } func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { + opai_titles := make([]oapi.Title, 0) - cursor := oapi.CursorObj{ - Id: 1, + mqreq := rmq.RabbitRequest{ + Timestamp: time.Now(), } word := Word2Sqlc(request.Params.Word) - status, err := TitleStatus2Sqlc(request.Params.Status) - if err != nil { - log.Errorf("%v", err) - return oapi.GetTitles400Response{}, err + if word != nil { + mqreq.Name = *word } + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) if err != nil { log.Errorf("%v", err) return oapi.GetTitles400Response{}, err } - // param = nil means it will not be used - titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{ + if season != nil { + mqreq.Season = *request.Params.ReleaseSeason + } + + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, err + } + if title_statuses != nil { + mqreq.Statuses = *request.Params.Status + } + + if request.Params.ExtSearch != nil && *request.Params.ExtSearch { + + // Структура для ответа (должна совпадать с тем, что шлёт микросервис) + var reply struct { + Status string `json:"status"` + Result string `json:"result"` + Preview string `json:"preview_url"` + } + + // Делаем RPC-вызов — и ЖДЁМ ответа + err := s.RPCclient.Call( + ctx, + "svc.media.process.requests", // ← очередь микросервиса + mqreq, + &reply, + ) + if err != nil { + log.Errorf("RabitMQ: %v", err) + // return oapi.GetTitles500Response{}, err + } + // // Возвращаем результат + // return oapi.ProcessMedia200JSONResponse{ + // Status: reply.Status, + // Result: reply.Result, + // Preview: reply.Preview, + // }, nil + } + + params := sqlc.SearchTitlesParams{ Word: word, - Status: status, + TitleStatuses: title_statuses, Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, - Forward: true, - SortBy: "id", + Forward: true, // default + SortBy: "id", // default Limit: request.Params.Limit, - }) + } + + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } + if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) + if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + return oapi.GetTitles400Response{}, nil + } + } + } + // param = nil means it will not be used + titles, err := s.db.SearchTitles(ctx, params) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil @@ -252,15 +248,58 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje return oapi.GetTitles204Response{}, nil } + var new_cursor oapi.CursorObj + for _, title := range titles { - t, err := s.mapTitle(ctx, title) + _title := sqlc.GetTitleByIDRow{ + ID: title.ID, + // StudioID: title.StudioID, + PosterID: title.PosterID, + TitleStatus: title.TitleStatus, + Rating: title.Rating, + RatingCount: title.RatingCount, + ReleaseYear: title.ReleaseYear, + ReleaseSeason: title.ReleaseSeason, + Season: title.Season, + EpisodesAired: title.EpisodesAired, + EpisodesAll: title.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: title.TitleStorageType, + TitleImagePath: title.TitleImagePath, + TitleNames: title.TitleNames, + TagNames: title.TagNames, + StudioName: title.StudioName, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + // if title.TitleStorageType != nil { + // s := *title.TitleStorageType + // _title.TitleStorageType = string(s) + // } + + t, err := s.mapTitle(_title) if err != nil { log.Errorf("%v", err) return oapi.GetTitles500Response{}, nil } opai_titles = append(opai_titles, t) + + new_cursor.Id = t.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := fmt.Sprint(*t.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Rating, 'f', -1, 64) + new_cursor.Param = &tmp + } + } } - return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil + return oapi.GetTitles200JSONResponse{Cursor: new_cursor, Data: opai_titles}, nil } diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go index 0fa903f..d6faade 100644 --- a/modules/backend/handlers/users.go +++ b/modules/backend/handlers/users.go @@ -2,82 +2,486 @@ package handlers import ( "context" + "errors" + "fmt" oapi "nyanimedb/api" sqlc "nyanimedb/sql" + "strconv" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/oapi-codegen/runtime/types" + log "github.com/sirupsen/logrus" ) -// type Server struct { -// db *sqlc.Queries -// } +const ( + pgErrDuplicateKey = "23505" +) -// func NewServer(db *sqlc.Queries) Server { -// return Server{db: db} -// } - -// func parseInt64(s string) (int32, error) { -// i, err := strconv.ParseInt(s, 10, 64) -// return int32(i), err -// } - -func mapUser(u sqlc.GetUserByIDRow) oapi.User { +func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { + i := oapi.Image{ + Id: u.AvatarID, + ImagePath: u.ImagePath, + } + s, err := sql2StorageType(u.StorageType) + if err != nil { + return oapi.User{}, fmt.Errorf("mapUser, storage type: %v", err) + } + i.StorageType = s return oapi.User{ - AvatarId: u.AvatarID, + Image: &i, CreationDate: &u.CreationDate, DispName: u.DispName, Id: &u.ID, - Mail: (*types.Email)(u.Mail), + Mail: StringToEmail(u.Mail), Nickname: u.Nickname, UserDesc: u.UserDesc, - } + }, nil } -func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) { +func (s Server) GetUsersId(ctx context.Context, req oapi.GetUsersIdRequestObject) (oapi.GetUsersIdResponseObject, error) { userID, err := parseInt64(req.UserId) if err != nil { - return oapi.GetUsersUserId404Response{}, nil + return oapi.GetUsersId404Response{}, nil } - user, err := s.db.GetUserByID(context.TODO(), int64(userID)) + _user, err := s.db.GetUserByID(context.TODO(), userID) if err != nil { if err == pgx.ErrNoRows { - return oapi.GetUsersUserId404Response{}, nil + return oapi.GetUsersId404Response{}, nil } return nil, err } - return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil + user, err := mapUser(_user) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUsersId500Response{}, err + } + return oapi.GetUsersId200JSONResponse(user), nil } -func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) { +func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { + if p_date.Valid { + t := p_date.Time + return &t + } + return nil +} - var rate int32 = 9 - var review_id int64 = 3 - time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) +// func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) { +// var sqlc_status SqlcUserStatus +// if s == nil { +// return &sqlc_status, nil +// } +// for _, t := range *s { +// switch t { +// case oapi.UserTitleStatusFinished: +// sqlc_status.finished = "finished" +// case oapi.UserTitleStatusDropped: +// sqlc_status.dropped = "dropped" +// case oapi.UserTitleStatusPlanned: +// sqlc_status.planned = "planned" +// case oapi.UserTitleStatusInProgress: +// sqlc_status.in_progress = "in-progress" +// default: +// return nil, fmt.Errorf("unexpected tittle status: %s", t) +// } +// } +// return &sqlc_status, nil +// } - var userTitles = []oapi.UserTitle{ - { - UserId: 101, - TitleId: 2001, - Status: oapi.UserTitleStatusFinished, - Rate: &rate, - Ctime: &time, - }, - { - UserId: 102, - TitleId: 2002, - Status: oapi.UserTitleStatusInProgress, - ReviewId: &review_id, - Ctime: &time, - }, - { - UserId: 103, - TitleId: 2003, - Status: oapi.UserTitleStatusDropped, - Ctime: &time, - }, +func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) { + var status oapi.UserTitleStatus + + switch s { + case sqlc.UsertitleStatusTFinished: + status = oapi.UserTitleStatusFinished + case sqlc.UsertitleStatusTDropped: + status = oapi.UserTitleStatusDropped + case sqlc.UsertitleStatusTPlanned: + status = oapi.UserTitleStatusPlanned + case sqlc.UsertitleStatusTInProgress: + status = oapi.UserTitleStatusInProgress + default: + return status, fmt.Errorf("unexpected tittle status: %s", s) } - return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil + return status, nil +} + +func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, error) { + var sqlc_status []sqlc.UsertitleStatusT + if s == nil { + return nil, nil + } + for _, t := range *s { + switch t { + case oapi.UserTitleStatusFinished: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTFinished) + case oapi.UserTitleStatusInProgress: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTInProgress) + case oapi.UserTitleStatusDropped: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTDropped) + case oapi.UserTitleStatusPlanned: + sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTPlanned) + default: + return nil, fmt.Errorf("unexpected tittle status: %s", t) + } + } + return sqlc_status, nil +} + +func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) { + var sqlc_status sqlc.UsertitleStatusT = sqlc.UsertitleStatusTFinished + if s == nil { + return &sqlc_status, nil + } + + switch *s { + case oapi.UserTitleStatusFinished: + sqlc_status = sqlc.UsertitleStatusTFinished + case oapi.UserTitleStatusInProgress: + sqlc_status = sqlc.UsertitleStatusTInProgress + case oapi.UserTitleStatusDropped: + sqlc_status = sqlc.UsertitleStatusTDropped + case oapi.UserTitleStatusPlanned: + sqlc_status = sqlc.UsertitleStatusTPlanned + default: + return nil, fmt.Errorf("unexpected tittle status: %s", *s) + } + + return &sqlc_status, nil +} + +func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) { + + oapi_usertitle := oapi.UserTitle{ + Ctime: &t.UserCtime, + Rate: t.UserRate, + ReviewId: t.ReviewID, + // Status: , + // Title: , + UserId: t.UserID, + } + + status, err := sql2usertitlestatus(t.UsertitleStatus) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Status = status + + _title := sqlc.GetTitleByIDRow{ + ID: t.ID, + // StudioID: title.StudioID, + PosterID: t.PosterID, + TitleStatus: t.TitleStatus, + Rating: t.Rating, + RatingCount: t.RatingCount, + ReleaseYear: t.ReleaseYear, + ReleaseSeason: t.ReleaseSeason, + Season: t.Season, + EpisodesAired: t.EpisodesAired, + EpisodesAll: t.EpisodesAll, + // EpisodesLen: title.EpisodesLen, + TitleStorageType: t.TitleStorageType, + TitleImagePath: t.TitleImagePath, + StudioName: t.StudioName, + TitleNames: t.TitleNames, + TagNames: t.TagNames, + // StudioIllustID: title.StudioIllustID, + // StudioDesc: title.StudioDesc, + // StudioStorageType: title.StudioStorageType, + // StudioImagePath: title.StudioImagePath, + } + + oapi_title, err := s.mapTitle(_title) + if err != nil { + return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err) + } + oapi_usertitle.Title = &oapi_title + + return oapi_usertitle, nil +} + +func (s Server) GetUserTitles(ctx context.Context, request oapi.GetUserTitlesRequestObject) (oapi.GetUserTitlesResponseObject, error) { + + oapi_usertitles := make([]oapi.UserTitle, 0) + + word := Word2Sqlc(request.Params.Word) + + season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitles400Response{}, err + } + + // var statuses_sort []string + // if request.Params.Status != nil { + // for _, s := range *request.Params.Status { + // ss := string(s) // s type is alias for string + // statuses_sort = append(statuses_sort, ss) + // } + // } + + watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitles400Response{}, err + } + + title_statuses, err := TitleStatus2Sqlc(request.Params.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitles400Response{}, err + } + + userID, err := parseInt64(request.UserId) + if err != nil { + log.Errorf("get user titles: %v", err) + return oapi.GetUserTitles404Response{}, err + } + params := sqlc.SearchUserTitlesParams{ + UserID: userID, + Word: word, + TitleStatuses: title_statuses, + UsertitleStatuses: watch_status, + Rating: request.Params.Rating, + Rate: request.Params.MyRate, + ReleaseYear: request.Params.ReleaseYear, + ReleaseSeason: season, + Forward: true, // default + SortBy: "id", // default + Limit: request.Params.Limit, + } + + if request.Params.SortForward != nil { + params.Forward = *request.Params.SortForward + } + if request.Params.Sort != nil { + params.SortBy = string(*request.Params.Sort) + if request.Params.Cursor != nil { + // here we set CursorYear CursorID CursorRating fields + err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitles400Response{}, nil + } + } + } + // param = nil means it will not be used + titles, err := s.db.SearchUserTitles(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitles500Response{}, nil + } + if len(titles) == 0 { + return oapi.GetUserTitles204Response{}, nil + } + + var new_cursor oapi.CursorObj + + for _, title := range titles { + + t, err := s.mapUsertitle(ctx, title) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitles500Response{}, nil + } + oapi_usertitles = append(oapi_usertitles, t) + + new_cursor.Id = t.Title.Id + if request.Params.Sort != nil { + switch string(*request.Params.Sort) { + case "year": + tmp := fmt.Sprint(*t.Title.ReleaseYear) + new_cursor.Param = &tmp + case "rating": + tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) // падает + new_cursor.Param = &tmp + } + } + } + + return oapi.GetUserTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil +} + +func EmailToStringPtr(e *types.Email) *string { + if e == nil { + return nil + } + s := string(*e) + return &s +} + +func StringToEmail(e *string) *types.Email { + if e == nil { + return nil + } + s := types.Email(*e) + return &s +} + +// UpdateUser implements oapi.StrictServerInterface. +func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestObject) (oapi.UpdateUserResponseObject, error) { + + params := sqlc.UpdateUserParams{ + AvatarID: request.Body.AvatarId, + DispName: request.Body.DispName, + UserDesc: request.Body.UserDesc, + Mail: EmailToStringPtr(request.Body.Mail), + UserID: request.UserId, + } + + user, err := s.db.UpdateUser(ctx, params) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUser500Response{}, nil + } + + oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit + // AvatarId: user.AvatarID, + CreationDate: &user.CreationDate, + DispName: user.DispName, + Id: &user.ID, + Mail: StringToEmail(user.Mail), + Nickname: user.Nickname, + UserDesc: user.UserDesc, + } + + return oapi.UpdateUser200JSONResponse(oapi_user), nil +} + +func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) { + //TODO: add review if exists + status, err := UserTitleStatus2Sqlc1(&request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle400Response{}, nil + } + + params := sqlc.InsertUserTitleParams{ + UserID: request.UserId, + TitleID: request.Body.TitleId, + Status: *status, + Rate: request.Body.Rate, + } + + user_title, err := s.db.InsertUserTitle(ctx, params) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + // fmt.Println(pgErr.Message) // => syntax error at end of input + // fmt.Println(pgErr.Code) // => 42601 + if pgErr.Code == pgErrDuplicateKey { //duplicate key value + return oapi.AddUserTitle409Response{}, nil + } + } else { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.AddUserTitle500Response{}, nil + } + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil +} + +// DeleteUserTitle implements oapi.StrictServerInterface. +func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) { + params := sqlc.DeleteUserTitleParams{ + UserID: request.UserId, + TitleID: request.TitleId, + } + _, err := s.db.DeleteUserTitle(ctx, params) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.DeleteUserTitle404Response{}, nil + } + log.Errorf("%v", err) + return oapi.DeleteUserTitle500Response{}, nil + } + return oapi.DeleteUserTitle200Response{}, nil +} + +// UpdateUserTitle implements oapi.StrictServerInterface. +func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitleRequestObject) (oapi.UpdateUserTitleResponseObject, error) { + + status, err := UserTitleStatus2Sqlc1(request.Body.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUserTitle400Response{}, nil + } + params := sqlc.UpdateUserTitleParams{ + Status: status, + Rate: request.Body.Rate, + UserID: request.UserId, + TitleID: request.TitleId, + } + + user_title, err := s.db.UpdateUserTitle(ctx, params) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.UpdateUserTitle404Response{}, nil + } + log.Errorf("%v", err) + return oapi.UpdateUserTitle500Response{}, nil + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.UpdateUserTitle500Response{}, nil + } + + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil +} + +func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleRequestObject) (oapi.GetUserTitleResponseObject, error) { + user_title, err := s.db.GetUserTitleByID(ctx, sqlc.GetUserTitleByIDParams{ + TitleID: request.TitleId, + UserID: request.UserId, + }) + if err != nil { + if err == pgx.ErrNoRows { + return oapi.GetUserTitle404Response{}, nil + } else { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + } + oapi_status, err := sql2usertitlestatus(user_title.Status) + if err != nil { + log.Errorf("%v", err) + return oapi.GetUserTitle500Response{}, nil + } + oapi_usertitle := oapi.UserTitleMini{ + Ctime: &user_title.Ctime, + Rate: user_title.Rate, + ReviewId: user_title.ReviewID, + Status: oapi_status, + TitleId: user_title.TitleID, + UserId: user_title.UserID, + } + + return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil } diff --git a/modules/backend/main.go b/modules/backend/main.go index 3ac6603..9f992a5 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "net/http" sqlc "nyanimedb/sql" "os" "reflect" @@ -10,11 +11,14 @@ import ( oapi "nyanimedb/api" handlers "nyanimedb/modules/backend/handlers" + "nyanimedb/modules/backend/rmq" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/pelletier/go-toml/v2" + "github.com/rabbitmq/amqp091-go" + log "github.com/sirupsen/logrus" ) var AppConfig Config @@ -43,12 +47,27 @@ func main() { queries := sqlc.New(pool) - server := handlers.NewServer(queries) + // === RabbitMQ setup === + rmqURL := os.Getenv("RABBITMQ_URL") + if rmqURL == "" { + rmqURL = "amqp://guest:guest@rabbitmq:5672/" + } + + rmqConn, err := amqp091.Dial(rmqURL) + if err != nil { + log.Fatalf("Failed to connect to RabbitMQ: %v", err) + } + defer rmqConn.Close() + + publisher := rmq.NewPublisher(rmqConn) + rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) + + server := handlers.NewServer(queries, publisher, rpcClient) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production - AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, @@ -60,24 +79,12 @@ func main() { // сюда можно добавить middlewares, если нужно []oapi.StrictMiddlewareFunc{}, )) - // r.GET("/", func(c *gin.Context) { - // c.HTML(http.StatusOK, "index.html", gin.H{ - // "title": "Welcome Page", - // "message": "Hello, Gin with HTML templates!", - // }) - // }) - // r.GET("/api", func(c *gin.Context) { - // items := []Item{ - // {ID: 1, Title: "First Item", Description: "This is the description of the first item."}, - // {ID: 2, Title: "Second Item", Description: "This is the description of the second item."}, - // {ID: 3, Title: "Third Item", Description: "This is the description of the third item."}, - // } - - // c.JSON(http.StatusOK, items) - // }) - - r.Run(":8080") + // Запуск + log.Infof("Server starting on :8080") + if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed { + log.Fatalf("server failed: %v", err) + } } func InitConfig() error { diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index c05edff..ff41cb1 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -9,9 +9,19 @@ VALUES ($1, $2) RETURNING id, storage_type, image_path; -- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1; +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE t.id = sqlc.arg('id')::bigint; -- name: GetStudioByID :one @@ -47,218 +57,347 @@ VALUES ( sqlc.arg('tag_names')::jsonb) RETURNING id, tag_names; --- -- name: ListUsers :many --- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date --- FROM users --- ORDER BY user_id --- LIMIT $1 OFFSET $2; - --- -- name: CreateUser :one --- INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) --- VALUES ($1, $2, $3, $4, $5, $6, $7) --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; - --- -- name: UpdateUser :one --- UPDATE users --- SET --- avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), --- disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), --- user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), --- passhash = COALESCE(sqlc.narg('passhash'), passhash) --- WHERE user_id = sqlc.arg('user_id') --- RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; - --- -- name: DeleteUser :exec --- DELETE FROM users --- WHERE user_id = $1; +-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), + disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), + user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), + mail = COALESCE(sqlc.narg('mail'), mail) +WHERE id = sqlc.arg('user_id') +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- name: GetTitleByID :one -SELECT * -FROM titles -WHERE id = sqlc.arg('title_id')::bigint; +-- sqlc.struct: TitlesFull +SELECT + t.*, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path + +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) + +WHERE t.id = sqlc.arg('title_id')::bigint +GROUP BY + t.id, i.id, s.id, si.id; -- name: SearchTitles :many SELECT - * -FROM titles -WHERE - CASE - WHEN sqlc.narg('word')::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name + +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END END - AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t) - AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) - AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) - AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) -ORDER BY - -- Основной ключ: выбранное поле - CASE - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year - WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END ASC, - CASE - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year - WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating - -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END DESC, - - -- Вторичный ключ: id — только если НЕ сортируем по id - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id - END ASC, - CASE - WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id - END DESC -LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit --- OFFSET sqlc.narg('offset')::int; - --- name: SearchUserTitles :many -SELECT - * -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN sqlc.narg('word')::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true - END + ELSE true + END + ) - AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t) + AND ( + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) + ) AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) - AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t) + +GROUP BY + t.id, i.id, s.id + +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + END + END ASC, + CASE WHEN NOT sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + END + END DESC, + + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit --- -- name: ListTitles :many --- SELECT title_id, title_names, studio_id, poster_id, signal_ids, --- title_status, rating, rating_count, release_year, release_season, --- season, episodes_aired, episodes_all, episodes_len --- FROM titles --- ORDER BY title_id --- LIMIT $1 OFFSET $2; +-- name: SearchUserTitles :many +SELECT + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name --- -- name: UpdateTitle :one --- UPDATE titles --- SET --- title_names = COALESCE(sqlc.narg('title_names'), title_names), --- studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), --- poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), --- signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), --- title_status = COALESCE(sqlc.narg('title_status'), title_status), --- release_year = COALESCE(sqlc.narg('release_year'), release_year), --- release_season = COALESCE(sqlc.narg('release_season'), release_season), --- episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), --- episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), --- episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) --- WHERE title_id = sqlc.arg('title_id') --- RETURNING *; +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + u.user_id = sqlc.arg('user_id')::bigint + AND + CASE + WHEN sqlc.arg('forward')::boolean THEN + -- forward: greater than cursor (next page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year > sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating > sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id > sqlc.narg('cursor_id')::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE sqlc.arg('sort_by')::text + WHEN 'year' THEN + (sqlc.narg('cursor_year')::int IS NULL) OR + (t.release_year < sqlc.narg('cursor_year')::int) OR + (t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'rating' THEN + (sqlc.narg('cursor_rating')::float IS NULL) OR + (t.rating < sqlc.narg('cursor_rating')::float) OR + (t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint) + + WHEN 'id' THEN + (sqlc.narg('cursor_id')::bigint IS NULL) OR + (t.id < sqlc.narg('cursor_id')::bigint) + + ELSE true + END + END + + AND ( + CASE + WHEN sqlc.narg('word')::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) + + AND ( + sqlc.narg('title_statuses')::title_status_t[] IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL + OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0 + OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[]) + ) + AND ( + sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL + OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0 + OR u.status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[]) + ) + AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int) + AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + +GROUP BY + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + +ORDER BY + CASE WHEN sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate + END + END ASC, + CASE WHEN NOT sqlc.arg('forward')::boolean THEN + CASE + WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id + WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year + WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating + WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate + END + END DESC, + + CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- name: GetReviewByID :one SELECT * FROM reviews WHERE review_id = sqlc.arg('review_id')::bigint; --- -- name: CreateReview :one --- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date; +-- name: InsertUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + sqlc.arg('user_id')::bigint, + sqlc.arg('title_id')::bigint, + sqlc.arg('status')::usertitle_status_t, + sqlc.narg('rate')::int, + sqlc.narg('review_id')::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime; --- -- name: UpdateReview :one --- UPDATE reviews --- SET --- image_ids = COALESCE(sqlc.narg('image_ids'), image_ids), --- review_text = COALESCE(sqlc.narg('review_text'), review_text) --- WHERE review_id = sqlc.arg('review_id') --- RETURNING *; +-- name: UpdateUserTitle :one +-- Fails with sql.ErrNoRows if (user_id, title_id) not found +UPDATE usertitles +SET + status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), + rate = COALESCE(sqlc.narg('rate')::int, rate) +WHERE + user_id = sqlc.arg('user_id') + AND title_id = sqlc.arg('title_id') +RETURNING *; --- -- name: DeleteReview :exec --- DELETE FROM reviews --- WHERE review_id = $1; +-- name: DeleteUserTitle :one +DELETE FROM usertitles +WHERE user_id = sqlc.arg('user_id') + AND title_id = sqlc.arg('title_id') +RETURNING *; --- name: ListReviewsByTitle :many --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE title_id = $1 --- ORDER BY creation_date DESC --- LIMIT $2 OFFSET $3; - --- -- name: ListReviewsByUser :many --- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date --- FROM reviews --- WHERE user_id = $1 --- ORDER BY creation_date DESC --- LIMIT $2 OFFSET $3; - --- -- name: GetUserTitle :one --- SELECT usertitle_id, user_id, title_id, status, rate, review_id --- FROM usertitles --- WHERE user_id = $1 AND title_id = $2; - --- -- name: ListUserTitles :many --- SELECT usertitle_id, user_id, title_id, status, rate, review_id --- FROM usertitles --- WHERE user_id = $1 --- ORDER BY usertitle_id --- LIMIT $2 OFFSET $3; - --- -- name: CreateUserTitle :one --- INSERT INTO usertitles (user_id, title_id, status, rate, review_id) --- VALUES ($1, $2, $3, $4, $5) --- RETURNING usertitle_id, user_id, title_id, status, rate, review_id; - --- -- name: UpdateUserTitle :one --- UPDATE usertitles --- SET --- status = COALESCE(sqlc.narg('status'), status), --- rate = COALESCE(sqlc.narg('rate'), rate), --- review_id = COALESCE(sqlc.narg('review_id'), review_id) --- WHERE user_id = $1 AND title_id = $2 --- RETURNING *; - --- -- name: DeleteUserTitle :exec --- DELETE FROM usertitles --- WHERE user_id = $1 AND ($2::int IS NULL OR title_id = $2); - --- -- name: ListTags :many --- SELECT tag_id, tag_names --- FROM tags --- ORDER BY tag_id --- LIMIT $1 OFFSET $2; \ No newline at end of file +-- name: GetUserTitleByID :one +SELECT + ut.* +FROM usertitles as ut +WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint; \ No newline at end of file diff --git a/modules/backend/rmq/rabbit.go b/modules/backend/rmq/rabbit.go new file mode 100644 index 0000000..52c1979 --- /dev/null +++ b/modules/backend/rmq/rabbit.go @@ -0,0 +1,261 @@ +package rmq + +import ( + "context" + "encoding/json" + "fmt" + oapi "nyanimedb/api" + "sync" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitRequest struct { + Name string `json:"name"` + Statuses []oapi.TitleStatus `json:"statuses,omitempty"` + Rating float64 `json:"rating,omitempty"` + Year int32 `json:"year,omitempty"` + Season oapi.ReleaseSeason `json:"season,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// Publisher — потокобезопасный публикатор с пулом каналов. +type Publisher struct { + conn *amqp.Connection + pool *sync.Pool +} + +// NewPublisher создаёт новый Publisher. +// conn должен быть уже установленным и healthy. +// Рекомендуется передавать durable connection с reconnect-логикой. +func NewPublisher(conn *amqp.Connection) *Publisher { + return &Publisher{ + conn: conn, + pool: &sync.Pool{ + New: func() any { + ch, err := conn.Channel() + if err != nil { + // Паника уместна: невозможность открыть канал — критическая ошибка инициализации + panic(fmt.Errorf("rmqpool: failed to create channel: %w", err)) + } + return ch + }, + }, + } +} + +// Publish публикует сообщение в указанную очередь. +// Очередь объявляется как durable (если не существует). +// Поддерживает context для отмены/таймаута. +func (p *Publisher) Publish( + ctx context.Context, + queueName string, + payload RabbitRequest, + opts ...PublishOption, +) error { + // Применяем опции + options := &publishOptions{ + contentType: "application/json", + deliveryMode: amqp.Persistent, + timestamp: time.Now(), + } + for _, opt := range opts { + opt(options) + } + + // Сериализуем payload + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("rmqpool: failed to marshal payload: %w", err) + } + + // Берём канал из пула + ch := p.getChannel() + if ch == nil { + return fmt.Errorf("rmqpool: channel is nil (connection may be closed)") + } + defer p.returnChannel(ch) + + // Объявляем очередь (idempotent) + q, err := ch.QueueDeclare( + queueName, + true, // durable + false, // auto-delete + false, // exclusive + false, // no-wait + nil, // args + ) + if err != nil { + return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err) + } + + // Подготавливаем сообщение + msg := amqp.Publishing{ + DeliveryMode: options.deliveryMode, + ContentType: options.contentType, + Timestamp: options.timestamp, + Body: body, + } + + // Публикуем с учётом контекста + done := make(chan error, 1) + go func() { + err := ch.Publish( + "", // exchange (default) + q.Name, // routing key + false, // mandatory + false, // immediate + msg, + ) + done <- err + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (p *Publisher) getChannel() *amqp.Channel { + raw := p.pool.Get() + if raw == nil { + ch, _ := p.conn.Channel() + return ch + } + ch := raw.(*amqp.Channel) + if ch.IsClosed() { // ← теперь есть! + ch.Close() // освободить ресурсы + ch, _ = p.conn.Channel() + } + return ch +} + +// returnChannel возвращает канал в пул, если он жив. +func (p *Publisher) returnChannel(ch *amqp.Channel) { + if ch != nil && !ch.IsClosed() { + p.pool.Put(ch) + } +} + +// PublishOption позволяет кастомизировать публикацию. +type PublishOption func(*publishOptions) + +type publishOptions struct { + contentType string + deliveryMode uint8 + timestamp time.Time +} + +// WithContentType устанавливает Content-Type (по умолчанию "application/json"). +func WithContentType(ct string) PublishOption { + return func(o *publishOptions) { o.contentType = ct } +} + +// WithTransient делает сообщение transient (не сохраняется на диск). +// По умолчанию — Persistent. +func WithTransient() PublishOption { + return func(o *publishOptions) { o.deliveryMode = amqp.Transient } +} + +// WithTimestamp устанавливает кастомную метку времени. +func WithTimestamp(ts time.Time) PublishOption { + return func(o *publishOptions) { o.timestamp = ts } +} + +type RPCClient struct { + conn *amqp.Connection + timeout time.Duration +} + +func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient { + return &RPCClient{conn: conn, timeout: timeout} +} + +// Call отправляет запрос в очередь и ждёт ответа. +// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}). +func (c *RPCClient) Call( + ctx context.Context, + requestQueue string, + request RabbitRequest, + replyPayload any, +) error { + // 1. Создаём временный канал (не из пула!) + ch, err := c.conn.Channel() + if err != nil { + return fmt.Errorf("channel: %w", err) + } + defer ch.Close() + + // 2. Создаём временную очередь для ответов + q, err := ch.QueueDeclare( + "", // auto name + false, // not durable + true, // exclusive + true, // auto-delete + false, + nil, + ) + if err != nil { + return fmt.Errorf("reply queue: %w", err) + } + + // 3. Подписываемся на ответы + msgs, err := ch.Consume( + q.Name, + "", + true, // auto-ack + true, // exclusive + false, + false, + nil, + ) + if err != nil { + return fmt.Errorf("consume: %w", err) + } + + // 4. Готовим correlation ID + corrID := time.Now().UnixNano() + + // 5. Сериализуем запрос + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + // 6. Публикуем запрос + err = ch.Publish( + "", + requestQueue, + false, + false, + amqp.Publishing{ + ContentType: "application/json", + CorrelationId: fmt.Sprintf("%d", corrID), + ReplyTo: q.Name, + Timestamp: time.Now(), + Body: body, + }, + ) + if err != nil { + return fmt.Errorf("publish: %w", err) + } + + // 7. Ждём ответ с таймаутом + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + for { + select { + case msg := <-msgs: + if msg.CorrelationId == fmt.Sprintf("%d", corrID) { + return json.Unmarshal(msg.Body, replyPayload) + } + // игнорируем другие сообщения (маловероятно, но возможно) + case <-ctx.Done(): + return ctx.Err() // timeout or cancelled + } + } +} diff --git a/modules/frontend/nginx-default.conf b/modules/frontend/nginx-default.conf index a538968..c3a851f 100644 --- a/modules/frontend/nginx-default.conf +++ b/modules/frontend/nginx-default.conf @@ -19,6 +19,15 @@ server { proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } + location /auth/ { + rewrite ^/auth/(.*)$ /$1 break; + proxy_pass http://nyanimedb-auth:8082/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } #error_page 404 /404.html; error_page 500 502 503 504 /50x.html; diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 5a25313..95b59e3 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -2,22 +2,38 @@ import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./pages/UserPage/UserPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage"; +import TitlePage from "./pages/TitlePage/TitlePage"; import { LoginPage } from "./pages/LoginPage/LoginPage"; import { Header } from "./components/Header/Header"; const App: React.FC = () => { - const username = "nihonium"; + const username = localStorage.getItem("username") || undefined; + const userId = localStorage.getItem("userId"); + return (
- } /> {/* <-- маршрут для логина */} - } /> {/* <-- можно использовать тот же компонент для регистрации */} + {/* auth */} + } /> + } /> + {/*} />*/} + + {/* users */} + {/*} />*/} } /> + : } + /> + + {/* titles */} } /> + } /> ); }; + export default App; \ No newline at end of file diff --git a/modules/frontend/src/api/core/OpenAPI.ts b/modules/frontend/src/api/core/OpenAPI.ts index 6ce873e..185e5c3 100644 --- a/modules/frontend/src/api/core/OpenAPI.ts +++ b/modules/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://10.1.0.65:8081/api/v1', + BASE: '/api/v1', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/api/index.ts b/modules/frontend/src/api/index.ts index 80ae491..9013fc7 100644 --- a/modules/frontend/src/api/index.ts +++ b/modules/frontend/src/api/index.ts @@ -12,6 +12,7 @@ export type { CursorObj } from './models/CursorObj'; export type { Image } from './models/Image'; export type { ReleaseSeason } from './models/ReleaseSeason'; export type { Review } from './models/Review'; +export type { StorageType } from './models/StorageType'; export type { Studio } from './models/Studio'; export type { Tag } from './models/Tag'; export type { Tags } from './models/Tags'; @@ -21,6 +22,7 @@ export type { TitleSort } from './models/TitleSort'; export type { TitleStatus } from './models/TitleStatus'; export type { User } from './models/User'; export type { UserTitle } from './models/UserTitle'; +export type { UserTitleMini } from './models/UserTitleMini'; export type { UserTitleStatus } from './models/UserTitleStatus'; export { DefaultService } from './services/DefaultService'; diff --git a/modules/frontend/src/api/models/Image.ts b/modules/frontend/src/api/models/Image.ts index 1317db7..887bf2f 100644 --- a/modules/frontend/src/api/models/Image.ts +++ b/modules/frontend/src/api/models/Image.ts @@ -2,9 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { StorageType } from './StorageType'; export type Image = { id?: number; - storage_type?: string; + storage_type?: StorageType; image_path?: string; }; diff --git a/modules/frontend/src/api/models/StorageType.ts b/modules/frontend/src/api/models/StorageType.ts new file mode 100644 index 0000000..f6d086b --- /dev/null +++ b/modules/frontend/src/api/models/StorageType.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Image storage type + */ +export type StorageType = 's3' | 'local'; diff --git a/modules/frontend/src/api/models/Title.ts b/modules/frontend/src/api/models/Title.ts index 4da7aa3..9ffdeb6 100644 --- a/modules/frontend/src/api/models/Title.ts +++ b/modules/frontend/src/api/models/Title.ts @@ -2,4 +2,30 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type Title = Record; +import type { Image } from './Image'; +import type { ReleaseSeason } from './ReleaseSeason'; +import type { Studio } from './Studio'; +import type { Tags } from './Tags'; +import type { TitleStatus } from './TitleStatus'; +export type Title = { + /** + * Unique title ID (primary key) + */ + id: number; + /** + * Localized titles. Key = language (ISO 639-1), value = list of names + */ + title_names: Record>; + studio?: Studio; + tags: Tags; + poster?: Image; + title_status?: TitleStatus; + rating?: number; + rating_count?: number; + release_year?: number; + release_season?: ReleaseSeason; + episodes_aired?: number; + episodes_all?: number; + episodes_len?: Record; +}; + diff --git a/modules/frontend/src/api/models/User.ts b/modules/frontend/src/api/models/User.ts index cd76dbe..969023f 100644 --- a/modules/frontend/src/api/models/User.ts +++ b/modules/frontend/src/api/models/User.ts @@ -2,15 +2,13 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { Image } from './Image'; export type User = { /** * Unique user ID (primary key) */ id?: number; - /** - * ID of the user avatar (references images table) - */ - avatar_id?: number; + image?: Image; /** * User email */ diff --git a/modules/frontend/src/api/models/UserTitle.ts b/modules/frontend/src/api/models/UserTitle.ts index 26d5ddc..42b7919 100644 --- a/modules/frontend/src/api/models/UserTitle.ts +++ b/modules/frontend/src/api/models/UserTitle.ts @@ -2,4 +2,14 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type UserTitle = Record; +import type { Title } from './Title'; +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitle = { + user_id: number; + title?: Title; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/models/UserTitleMini.ts b/modules/frontend/src/api/models/UserTitleMini.ts new file mode 100644 index 0000000..2b223ce --- /dev/null +++ b/modules/frontend/src/api/models/UserTitleMini.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserTitleStatus } from './UserTitleStatus'; +export type UserTitleMini = { + user_id: number; + title_id: number; + status: UserTitleStatus; + rate?: number; + review_id?: number; + ctime?: string; +}; + diff --git a/modules/frontend/src/api/services/DefaultService.ts b/modules/frontend/src/api/services/DefaultService.ts index 52321b8..218b461 100644 --- a/modules/frontend/src/api/services/DefaultService.ts +++ b/modules/frontend/src/api/services/DefaultService.ts @@ -9,6 +9,7 @@ import type { TitleSort } from '../models/TitleSort'; import type { TitleStatus } from '../models/TitleStatus'; import type { User } from '../models/User'; import type { UserTitle } from '../models/UserTitle'; +import type { UserTitleMini } from '../models/UserTitleMini'; import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; @@ -20,7 +21,7 @@ export class DefaultService { * @param sort * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param rating * @param releaseYear * @param releaseSeason @@ -35,7 +36,7 @@ export class DefaultService { sort?: TitleSort, sortForward: boolean = true, word?: string, - status?: TitleStatus, + status?: Array, rating?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, @@ -78,7 +79,7 @@ export class DefaultService { * @returns Title Title description * @throws ApiError */ - public static getTitles1( + public static getTitle( titleId: number, fields: string = 'all', ): CancelablePromise { @@ -105,7 +106,7 @@ export class DefaultService { * @returns User User info * @throws ApiError */ - public static getUsers( + public static getUsersId( userId: string, fields: string = 'all', ): CancelablePromise<User> { @@ -125,45 +126,112 @@ export class DefaultService { }, }); } + /** + * Partially update a user account + * Update selected user profile fields (excluding password). + * Password updates must be done via the dedicated auth-service (`/auth/`). + * Fields not provided in the request body remain unchanged. + * + * @param userId User ID (primary key) + * @param requestBody + * @returns User User updated successfully. Returns updated user representation (excluding sensitive fields). + * @throws ApiError + */ + public static updateUser( + userId: number, + requestBody: { + /** + * ID of the user avatar (references `images.id`); set to `null` to remove avatar + */ + avatar_id?: number | null; + /** + * User email (must be unique and valid) + */ + mail?: string; + /** + * Username (alphanumeric + `_` or `-`, 3–16 chars) + */ + nickname?: string; + /** + * Display name + */ + disp_name?: string; + /** + * User description / bio + */ + user_desc?: string; + }, + ): CancelablePromise<User> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`, + 401: `Unauthorized — missing or invalid authentication token`, + 403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`, + 404: `User not found`, + 409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`, + 422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`, + 500: `Unknown server error`, + }, + }); + } /** * Get user titles * @param userId * @param cursor + * @param sort + * @param sortForward * @param word - * @param status + * @param status List of title statuses to filter * @param watchStatus * @param rating + * @param myRate * @param releaseYear * @param releaseSeason * @param limit * @param fields - * @returns UserTitle List of user titles + * @returns any List of user titles * @throws ApiError */ - public static getUsersTitles( + public static getUserTitles( userId: string, cursor?: string, + sort?: TitleSort, + sortForward: boolean = true, word?: string, - status?: TitleStatus, - watchStatus?: UserTitleStatus, + status?: Array<TitleStatus>, + watchStatus?: Array<UserTitleStatus>, rating?: number, + myRate?: number, releaseYear?: number, releaseSeason?: ReleaseSeason, limit: number = 10, fields: string = 'all', - ): CancelablePromise<Array<UserTitle>> { + ): CancelablePromise<{ + data: Array<UserTitle>; + cursor: CursorObj; + }> { return __request(OpenAPI, { method: 'GET', - url: '/users/{user_id}/titles/', + url: '/users/{user_id}/titles', path: { 'user_id': userId, }, query: { 'cursor': cursor, + 'sort': sort, + 'sort_forward': sortForward, 'word': word, 'status': status, 'watch_status': watchStatus, 'rating': rating, + 'my_rate': myRate, 'release_year': releaseYear, 'release_season': releaseSeason, 'limit': limit, @@ -171,8 +239,130 @@ export class DefaultService { }, errors: { 400: `Request params are not correct`, + 404: `User not found`, 500: `Unknown server error`, }, }); } + /** + * Add a title to a user + * User adding title to list af watched, status required + * @param userId ID of the user to assign the title to + * @param requestBody + * @returns UserTitleMini Title successfully added to user + * @throws ApiError + */ + public static addUserTitle( + userId: number, + requestBody: { + title_id: number; + status: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'POST', + url: '/users/{user_id}/titles', + path: { + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to assign titles to this user`, + 404: `User or Title not found`, + 409: `Conflict — title already assigned to user (if applicable)`, + 500: `Internal server error`, + }, + }); + } + /** + * Get user title + * @param userId + * @param titleId + * @returns UserTitleMini User titles + * @throws ApiError + */ + public static getUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'GET', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 400: `Request params are not correct`, + 404: `User or title not found`, + 500: `Unknown server error`, + }, + }); + } + /** + * Update a usertitle + * User updating title list of watched + * @param userId + * @param titleId + * @param requestBody + * @returns UserTitleMini Title successfully updated + * @throws ApiError + */ + public static updateUserTitle( + userId: number, + titleId: number, + requestBody: { + status?: UserTitleStatus; + rate?: number; + }, + ): CancelablePromise<UserTitleMini> { + return __request(OpenAPI, { + method: 'PATCH', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Invalid request body (missing fields, invalid types, etc.)`, + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to update title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } + /** + * Delete a usertitle + * User deleting title from list of watched + * @param userId + * @param titleId + * @returns any Title successfully deleted + * @throws ApiError + */ + public static deleteUserTitle( + userId: number, + titleId: number, + ): CancelablePromise<any> { + return __request(OpenAPI, { + method: 'DELETE', + url: '/users/{user_id}/titles/{title_id}', + path: { + 'user_id': userId, + 'title_id': titleId, + }, + errors: { + 401: `Unauthorized — missing or invalid auth token`, + 403: `Forbidden — user not allowed to delete title`, + 404: `User or Title not found`, + 500: `Internal server error`, + }, + }); + } } diff --git a/modules/frontend/src/auth/core/OpenAPI.ts b/modules/frontend/src/auth/core/OpenAPI.ts index 27bd73f..2d0edf8 100644 --- a/modules/frontend/src/auth/core/OpenAPI.ts +++ b/modules/frontend/src/auth/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: 'http://127.0.0.1:8082', + BASE: '/auth', VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'include', diff --git a/modules/frontend/src/auth/services/AuthService.ts b/modules/frontend/src/auth/services/AuthService.ts index bab9c77..94578d8 100644 --- a/modules/frontend/src/auth/services/AuthService.ts +++ b/modules/frontend/src/auth/services/AuthService.ts @@ -41,9 +41,9 @@ export class AuthService { pass: string; }, ): CancelablePromise<{ - success?: boolean; error?: string | null; user_id?: string | null; + user_name?: string | null; }> { return __request(OpenAPI, { method: 'POST', diff --git a/modules/frontend/src/components/Header/Header.tsx b/modules/frontend/src/components/Header/Header.tsx index 98b1295..26f1658 100644 --- a/modules/frontend/src/components/Header/Header.tsx +++ b/modules/frontend/src/components/Header/Header.tsx @@ -12,7 +12,7 @@ export const Header: React.FC<HeaderProps> = ({ username }) => { const toggleMenu = () => setMenuOpen(!menuOpen); return ( - <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50"> + <header className="w-full bg-white shadow-md sticky top-0 left-0 z-50"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="flex justify-between h-16 items-center"> diff --git a/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx new file mode 100644 index 0000000..0c9c741 --- /dev/null +++ b/modules/frontend/src/components/TitleStatusControls/TitleStatusControls.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { DefaultService } from "../../api"; +import type { UserTitleStatus } from "../../api"; +import { + ClockIcon, + CheckCircleIcon, + PlayCircleIcon, + XCircleIcon, +} from "@heroicons/react/24/solid"; + +// Статусы с иконками и подписью +const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [ + { status: "planned", icon: <ClockIcon className="w-5 h-5" />, label: "Planned" }, + { status: "finished", icon: <CheckCircleIcon className="w-5 h-5" />, label: "Finished" }, + { status: "in-progress", icon: <PlayCircleIcon className="w-5 h-5" />, label: "In Progress" }, + { status: "dropped", icon: <XCircleIcon className="w-5 h-5" />, label: "Dropped" }, +]; + +export function TitleStatusControls({ titleId }: { titleId: number }) { + const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null); + const [loading, setLoading] = useState(false); + + const userIdStr = localStorage.getItem("userId"); + const userId = userIdStr ? Number(userIdStr) : null; + + // --- Load initial status --- + useEffect(() => { + if (!userId) return; + + DefaultService.getUserTitle(userId, titleId) + .then((res) => setCurrentStatus(res.status)) + .catch(() => setCurrentStatus(null)); // 404 = user title does not exist + }, [titleId, userId]); + + // --- Handle click --- + const handleStatusClick = async (status: UserTitleStatus) => { + if (!userId || loading) return; + + setLoading(true); + + try { + // 1) Если кликнули на текущий статус — DELETE + if (currentStatus === status) { + await DefaultService.deleteUserTitle(userId, titleId); + setCurrentStatus(null); + return; + } + + // 2) Если другой статус — POST или PATCH + if (!currentStatus) { + // ещё нет записи — POST + const added = await DefaultService.addUserTitle(userId, { + title_id: titleId, + status, + }); + setCurrentStatus(added.status); + } else { + // уже есть запись — PATCH + const updated = await DefaultService.updateUserTitle(userId, titleId, { status }); + setCurrentStatus(updated.status); + } + } finally { + setLoading(false); + } + }; + + return ( + <div className="flex gap-2 flex-wrap justify-center mt-2"> + {STATUS_BUTTONS.map(btn => ( + <button + key={btn.status} + onClick={() => handleStatusClick(btn.status)} + disabled={loading} + className={` + px-3 py-1 rounded-md border flex items-center gap-1 transition + ${currentStatus === btn.status + ? "bg-blue-600 text-white border-blue-700" + : "bg-gray-200 text-black border-gray-300 hover:bg-gray-300"} + `} + title={btn.label} + > + {btn.icon} + <span>{btn.label}</span> + </button> + ))} + </div> + ); +} diff --git a/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx new file mode 100644 index 0000000..ad7d5df --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardHorizontal.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardHorizontal({ title }: { title: UserTitle }) { + return ( + <div style={{ + display: "flex", + gap: 12, + padding: 12, + border: "1px solid #ddd", + borderRadius: 8 + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={80} /> + )} + <div> + <h3>{title.title?.title_names["en"]}</h3> + <p>{title.title?.release_year} · {title.title?.release_season} · Rating: {title.title?.rating}</p> + <p>Status: {title.title?.title_status}</p> + </div> + </div> + ); +} diff --git a/modules/frontend/src/components/cards/UserTitleCardSquare.tsx b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx new file mode 100644 index 0000000..edcf1d5 --- /dev/null +++ b/modules/frontend/src/components/cards/UserTitleCardSquare.tsx @@ -0,0 +1,22 @@ +import type { UserTitle } from "../../api"; + +export function UserTitleCardSquare({ title }: { title: UserTitle }) { + return ( + <div style={{ + width: 160, + border: "1px solid #ddd", + padding: 8, + borderRadius: 8, + textAlign: "center" + }}> + {title.title?.poster?.image_path && ( + <img src={title.title?.poster.image_path} width={140} /> + )} + <div> + <h4>{title.title?.title_names["en"]}</h4> + <h5>{title.status}</h5> + <small>{title.title?.release_year} • {title.title?.rating}</small> + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/LoginPage/LoginPage.tsx b/modules/frontend/src/pages/LoginPage/LoginPage.tsx index dcd6965..89ee88c 100644 --- a/modules/frontend/src/pages/LoginPage/LoginPage.tsx +++ b/modules/frontend/src/pages/LoginPage/LoginPage.tsx @@ -18,17 +18,19 @@ export const LoginPage: React.FC = () => { try { if (isLogin) { const res = await AuthService.postAuthSignIn({ nickname, pass: password }); - if (res.success) { - // TODO: сохранить JWT в localStorage/cookie - console.log("Logged in user id:", res.user_id); - navigate("/"); // редирект после успешного входа + if (res.user_id && res.user_name) { + // Сохраняем user_id и username в localStorage + localStorage.setItem("userId", res.user_id); + localStorage.setItem("username", res.user_name); + + navigate("/profile"); // редирект на профиль } else { setError(res.error || "Login failed"); } } else { + // SignUp оставляем без сохранения данных const res = await AuthService.postAuthSignUp({ nickname, pass: password }); - if (res.success) { - console.log("User signed up with id:", res.user_id); + if (res.user_id) { setIsLogin(true); // переключаемся на login после регистрации } else { setError(res.error || "Sign up failed"); diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.module.css b/modules/frontend/src/pages/TitlePage/TitlePage.module.css deleted file mode 100644 index e69de29..0000000 diff --git a/modules/frontend/src/pages/TitlePage/TitlePage.tsx b/modules/frontend/src/pages/TitlePage/TitlePage.tsx index 7fe9de7..01f9c49 100644 --- a/modules/frontend/src/pages/TitlePage/TitlePage.tsx +++ b/modules/frontend/src/pages/TitlePage/TitlePage.tsx @@ -1,64 +1,108 @@ -// import React, { useEffect, useState } from "react"; -// import { useParams } from "react-router-dom"; -// import { DefaultService } from "../../api/services/DefaultService"; -// import type { User } from "../../api/models/User"; -// import styles from "./UserPage.module.css"; +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { DefaultService } from "../../api/services/DefaultService"; +import type { Title } from "../../api"; +import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; -// const UserPage: React.FC = () => { -// const { id } = useParams<{ id: string }>(); -// const [user, setUser] = useState<User | null>(null); -// const [loading, setLoading] = useState(true); -// const [error, setError] = useState<string | null>(null); +export default function TitlePage() { + const params = useParams(); + const titleId = Number(params.id); -// useEffect(() => { -// if (!id) return; + const [title, setTitle] = useState<Title | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); -// const getTitleInfo = async () => { -// try { -// const userInfo = await DefaultService.getTitle(id, "all"); -// setUser(userInfo); -// } catch (err) { -// console.error(err); -// setError("Failed to fetch user info."); -// } finally { -// setLoading(false); -// } -// }; -// getTitleInfo(); -// }, [id]); + // --------------------------- + // LOAD TITLE INFO + // --------------------------- + useEffect(() => { + const fetchTitle = async () => { + setLoading(true); + try { + const data = await DefaultService.getTitle(titleId, "all"); + setTitle(data); + setError(null); + } catch (err: any) { + console.error(err); + setError(err?.message || "Failed to fetch title"); + } finally { + setLoading(false); + } + }; + fetchTitle(); + }, [titleId]); -// if (loading) return <div className={styles.loader}>Loading...</div>; -// if (error) return <div className={styles.error}>{error}</div>; -// if (!user) return <div className={styles.error}>User not found.</div>; + const getTagsString = () => + title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); -// return ( -// <div className={styles.container}> -// <div className={styles.card}> -// <div className={styles.avatar}> -// {user.avatar_id ? ( -// <img -// src={`/images/${user.avatar_id}.png`} -// alt="User Avatar" -// className={styles.avatarImg} -// /> -// ) : ( -// <div className={styles.avatarPlaceholder}> -// {user.disp_name?.[0] || "U"} -// </div> -// )} -// </div> + if (loading) return <div className="mt-20 font-medium text-black">Loading title...</div>; + if (error) return <div className="mt-20 text-red-600 font-medium">{error}</div>; + if (!title) return null; -// <div className={styles.info}> -// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> -// <p className={styles.nickname}>@{user.nickname}</p> -// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} -// <p className={styles.created}> -// Joined: {new Date(user.creation_date).toLocaleDateString()} -// </p> -// </div> -// </div> -// </div> -// ); -// }; + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> + <div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> + {/* Poster + status buttons */} + <div className="flex flex-col items-center"> + <img + src={title.poster?.image_path || "/default-poster.png"} + alt={title.title_names?.en?.[0] || "Title poster"} + className="w-48 h-72 object-cover rounded-lg mb-4" + /> -// export default UserPage; + {/* Status buttons */} + <TitleStatusControls titleId={titleId} /> + </div> + + {/* Title info */} + <div className="flex-1 flex flex-col"> + <h1 className="text-3xl font-bold mb-2"> + {title.title_names?.en?.[0] || "Untitled"} + </h1> + + {title.studio && ( + <p className="text-gray-700 mb-1"> + Studio:{" "} + {title.studio.id ? ( + <Link + to={`/studios/${title.studio.id}`} + className="text-blue-600 hover:underline" + > + {title.studio.name} + </Link> + ) : ( + title.studio.name + )} + </p> + )} + + {title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>} + + {title.rating !== undefined && ( + <p className="text-gray-700 mb-1"> + Rating: {title.rating} ({title.rating_count} votes) + </p> + )} + + {title.release_year && ( + <p className="text-gray-700 mb-1"> + Released: {title.release_year} {title.release_season || ""} + </p> + )} + + {title.episodes_aired !== undefined && ( + <p className="text-gray-700 mb-1"> + Episodes: {title.episodes_aired}/{title.episodes_all} + </p> + )} + + {title.tags && title.tags.length > 0 && ( + <p className="text-gray-700 mb-1"> + Tags: {getTagsString()} + </p> + )} + </div> + </div> + </div> + ); +} diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css b/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css deleted file mode 100644 index f1d8c73..0000000 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.module.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx index 0fec3c8..c9911b9 100644 --- a/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx +++ b/modules/frontend/src/pages/TitlesPage/TitlesPage.tsx @@ -7,6 +7,7 @@ import { TitleCardSquare } from "../../components/cards/TitleCardSquare"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import type { CursorObj, Title, TitleSort } from "../../api"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { Link } from "react-router-dom"; const PAGE_SIZE = 10; @@ -135,11 +136,11 @@ const handleLoadMore = async () => { hasMore={!!cursor || nextPage.length > 1} loadingMore={loadingMore} onLoadMore={handleLoadMore} - renderItem={(title, layout) => - layout === "square" - ? <TitleCardSquare title={title} /> - : <TitleCardHorizontal title={title} /> - } + renderItem={(title, layout) => ( + <Link to={`/titles/${title.id}`} key={title.id} className="block"> + {layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} />} + </Link> + )} /> {!cursor && nextPage.length == 0 && ( diff --git a/modules/frontend/src/pages/UserPage/UserPage.module.css b/modules/frontend/src/pages/UserPage/UserPage.module.css deleted file mode 100644 index 7f350c8..0000000 --- a/modules/frontend/src/pages/UserPage/UserPage.module.css +++ /dev/null @@ -1,103 +0,0 @@ -body, -html { - width: 100%; - margin: 0; - background-color: #777; - color: #fff; -} - -html, -body, -#root { - height: 100%; -} - -.header { - width: 100vw; - padding: 30px 40px; - background: #f7f7f7; - display: flex; - align-items: center; - gap: 25px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - border-bottom: 1px solid #e5e5e5; - color: #000000; -} - -.avatarWrapper { - width: 120px; - height: 120px; - min-width: 120px; - border-radius: 50%; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background: #ddd; -} - -.avatarImg { - width: 100%; - height: 100%; - object-fit: cover; -} - -.avatarPlaceholder { - width: 100%; - height: 100%; - border-radius: 50%; - background: #ccc; - font-size: 42px; - font-weight: bold; - color: #555; - display: flex; - align-items: center; - justify-content: center; -} - -.userInfo { - display: flex; - flex-direction: column; -} - -.name { - font-size: 32px; - font-weight: 700; - margin: 0; -} - -.nickname { - font-size: 18px; - color: #666; - margin-top: 6px; -} - -.container { - max-width: 100vw; - width: 100%; - position: absolute; - top: 0%; - /* margin: 25px auto; */ - /* padding: 0 20px; */ -} - -.content { - margin-top: 20px; -} - -.desc { - font-size: 18px; - margin-bottom: 10px; -} - -.created { - font-size: 16px; - color: #888; -} - -.loader, -.error { - text-align: center; - margin-top: 40px; - font-size: 18px; -} diff --git a/modules/frontend/src/pages/UserPage/UserPage.tsx b/modules/frontend/src/pages/UserPage/UserPage.tsx index 52c5574..7cc0db5 100644 --- a/modules/frontend/src/pages/UserPage/UserPage.tsx +++ b/modules/frontend/src/pages/UserPage/UserPage.tsx @@ -1,67 +1,184 @@ -import React, { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; // <-- import +// pages/UserPage/UserPage.tsx +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; import { DefaultService } from "../../api/services/DefaultService"; -import type { User } from "../../api/models/User"; -import styles from "./UserPage.module.css"; +import { SearchBar } from "../../components/SearchBar/SearchBar"; +import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; +import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; +import { ListView } from "../../components/ListView/ListView"; +import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare"; +import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal"; +import type { User, UserTitle, CursorObj, TitleSort } from "../../api"; +import { Link } from "react-router-dom"; -const UserPage: React.FC = () => { - const { id } = useParams<{ id: string }>(); // <-- get user id from URL - const [user, setUser] = useState<User | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); +const PAGE_SIZE = 10; - useEffect(() => { - if (!id) return; - - const getUserInfo = async () => { - try { - const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id - setUser(userInfo); - } catch (err) { - console.error(err); - setError("Failed to fetch user info."); - } finally { - setLoading(false); - } - }; - getUserInfo(); - }, [id]); - - if (loading) return <div className={styles.loader}>Loading...</div>; - if (error) return <div className={styles.error}>{error}</div>; - if (!user) return <div className={styles.error}>User not found.</div>; - - return ( - <div className={styles.container}> - <div className={styles.header}> - <div className={styles.avatarWrapper}> - {user.avatar_id ? ( - <img - src={`/images/${user.avatar_id}.png`} - alt="User Avatar" - className={styles.avatarImg} - /> - ) : ( - <div className={styles.avatarPlaceholder}> - {user.disp_name?.[0] || "U"} - </div> - )} - </div> - - <div className={styles.userInfo}> - <h1 className={styles.name}>{user.disp_name || user.nickname}</h1> - <p className={styles.nickname}>@{user.nickname}</p> - {/* <p className={styles.created}> - Joined: {new Date(user.creation_date).toLocaleDateString()} - </p> */} - </div> - - <div className={styles.content}> - {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>} - </div> - </div> - </div> - ); +type UserPageProps = { + userId?: string; }; -export default UserPage; +export default function UserPage({ userId }: UserPageProps) { + const params = useParams(); + const id = userId || params?.id; + + const [user, setUser] = useState<User | null>(null); + const [loadingUser, setLoadingUser] = useState(true); + const [errorUser, setErrorUser] = useState<string | null>(null); + + // Для списка тайтлов + const [titles, setTitles] = useState<UserTitle[]>([]); + const [nextPage, setNextPage] = useState<UserTitle[]>([]); + const [cursor, setCursor] = useState<CursorObj | null>(null); + const [loadingTitles, setLoadingTitles] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState<TitleSort>("id"); + const [sortForward, setSortForward] = useState(true); + const [layout, setLayout] = useState<"square" | "horizontal">("square"); + + // --- Получение данных пользователя --- + useEffect(() => { + const fetchUser = async () => { + if (!id) return; + setLoadingUser(true); + try { + const result = await DefaultService.getUsersId(id, "all"); + setUser(result); + setErrorUser(null); + } catch (err: any) { + console.error(err); + setErrorUser(err?.message || "Failed to fetch user data"); + } finally { + setLoadingUser(false); + } + }; + fetchUser(); + }, [id]); + + // --- Получение списка тайтлов пользователя --- + const fetchPage = async (cursorObj: CursorObj | null) => { + if (!id) return { items: [], nextCursor: null }; + const cursorStr = cursorObj + ? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + : ""; + + try { + const result = await DefaultService.getUserTitles( + id, + cursorStr, + sort, + sortForward, + search.trim() || undefined, + undefined, // status фильтр, можно добавить + undefined, // watchStatus + undefined, // rating + undefined, // myRate + undefined, // releaseYear + undefined, // releaseSeason + PAGE_SIZE, + "all" + ); + + if (!result?.data?.length) return { items: [], nextCursor: null }; + + return { items: result.data, nextCursor: result.cursor ?? null }; + } catch (err: any) { + if (err.status === 204) return { items: [], nextCursor: null }; + throw err; + } + }; + + // Инициализация: загружаем сразу две страницы + useEffect(() => { + const initLoad = async () => { + setLoadingTitles(true); + setTitles([]); + setNextPage([]); + setCursor(null); + + const firstPage = await fetchPage(null); + const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null }; + + setTitles(firstPage.items); + setNextPage(secondPage.items); + setCursor(secondPage.nextCursor); + setLoadingTitles(false); + }; + initLoad(); + }, [id, search, sort, sortForward]); + + const handleLoadMore = async () => { + if (nextPage.length === 0) { + setLoadingMore(false); + return; + } + setLoadingMore(true); + + setTitles(prev => [...prev, ...nextPage]); + setNextPage([]); + + if (cursor) { + try { + const next = await fetchPage(cursor); + if (next.items.length > 0) setNextPage(next.items); + setCursor(next.nextCursor); + } catch (err) { + console.error(err); + } + } + + setLoadingMore(false); + }; + + return ( + <div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> + + {/* --- Карточка пользователя --- */} + {loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} + {errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} + {user && ( + <div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> + <img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> + <h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2> + {user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>} + {user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>} + {user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} + </div> + )} + + {/* --- Панель поиска, сортировки и лейаута --- */} + <div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center"> + <SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} /> + <LayoutSwitch layout={layout} setLayout={setLayout} /> + <TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} /> + </div> + + {/* --- Список тайтлов --- */} + {loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>} + {!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>} + + {titles.length > 0 && ( + <> + <ListView<UserTitle> + items={titles} + layout={layout} + hasMore={!!cursor || nextPage.length > 1} + loadingMore={loadingMore} + onLoadMore={handleLoadMore} + renderItem={(title, layout) => ( + <Link to={`/titles/${title.title?.id}`} key={title.title?.id} className="block"> + {layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />} + </Link> + )} + /> + + {!cursor && nextPage.length === 0 && ( + <div className="mt-6 font-medium text-black"> + Результатов больше нет, было найдено {titles.length} тайтлов. + </div> + )} + </> + )} + </div> + ); +} diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index e6ed628..3499fe2 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,6 +1,3 @@ --- TODO: --- maybe jsonb constraints --- clean unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); @@ -24,37 +21,24 @@ CREATE TABLE images ( image_path text UNIQUE NOT NULL ); -CREATE TABLE reviews ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - data text NOT NULL, - rating int CHECK (rating >= 0 AND rating <= 10), - user_id bigint REFERENCES users (id), - title_id bigint REFERENCES titles (id), - created_at timestamptz DEFAULT NOW() -); - -CREATE TABLE review_images ( - PRIMARY KEY (review_id, image_id), - review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, - image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE -); - CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - avatar_id bigint REFERENCES images (id), + avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, passhash text NOT NULL, mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'), disp_name text, user_desc text, - creation_date timestamptz NOT NULL, + creation_date timestamptz NOT NULL DEFAULT NOW(), last_login timestamptz ); + + CREATE TABLE studios ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_name text NOT NULL UNIQUE, - illust_id bigint REFERENCES images (id), + illust_id bigint REFERENCES images (id) ON DELETE SET NULL, studio_desc text ); @@ -64,7 +48,7 @@ CREATE TABLE titles ( -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, studio_id bigint NOT NULL REFERENCES studios (id), - poster_id bigint REFERENCES images (id), + poster_id bigint REFERENCES images (id) ON DELETE SET NULL, title_status title_status_t NOT NULL, rating float CHECK (rating >= 0 AND rating <= 10), rating_count int CHECK (rating_count >= 0), @@ -80,21 +64,36 @@ CREATE TABLE titles ( AND episodes_aired <= episodes_all)) ); +CREATE TABLE reviews ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + data text NOT NULL, + rating int CHECK (rating >= 0 AND rating <= 10), + user_id bigint REFERENCES users (id) ON DELETE SET NULL, + title_id bigint REFERENCES titles (id) ON DELETE CASCADE, + created_at timestamptz DEFAULT NOW() +); + +CREATE TABLE review_images ( + PRIMARY KEY (review_id, image_id), + review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + image_id bigint NOT NULL REFERENCES images(id) ON DELETE CASCADE +); + CREATE TABLE usertitles ( PRIMARY KEY (user_id, title_id), - user_id bigint NOT NULL REFERENCES users (id), - title_id bigint NOT NULL REFERENCES titles (id), + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, status usertitle_status_t NOT NULL, rate int CHECK (rate > 0 AND rate <= 10), - review_id bigint REFERENCES reviews (id), - ctime timestamptz + review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, + ctime timestamptz NOT NULL DEFAULT now() -- // TODO: series status ); CREATE TABLE title_tags ( PRIMARY KEY (title_id, tag_id), - title_id bigint NOT NULL REFERENCES titles (id), - tag_id bigint NOT NULL REFERENCES tags (id) + title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE ); CREATE TABLE signals ( @@ -105,17 +104,17 @@ CREATE TABLE signals ( pending boolean NOT NULL ); +CREATE TABLE external_services ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text UNIQUE NOT NULL +); + CREATE TABLE external_ids ( user_id bigint NOT NULL REFERENCES users (id), service_id bigint REFERENCES external_services (id), external_id text NOT NULL ); -CREATE TABLE external_services ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - name text UNIQUE NOT NULL -); - -- Functions CREATE OR REPLACE FUNCTION update_title_rating() RETURNS TRIGGER AS $$ @@ -169,4 +168,17 @@ EXECUTE FUNCTION update_title_rating(); CREATE TRIGGER trg_notify_new_signal AFTER INSERT ON signals FOR EACH ROW -EXECUTE FUNCTION notify_new_signal(); \ No newline at end of file +EXECUTE FUNCTION notify_new_signal(); + +CREATE OR REPLACE FUNCTION set_ctime() +RETURNS TRIGGER AS $$ +BEGIN + NEW.ctime = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_ctime_on_update +BEFORE UPDATE ON usertitles +FOR EACH ROW +EXECUTE FUNCTION set_ctime(); \ No newline at end of file diff --git a/sql/models.go b/sql/models.go index 93cecca..842d58c 100644 --- a/sql/models.go +++ b/sql/models.go @@ -6,6 +6,7 @@ package sqlc import ( "database/sql/driver" + "encoding/json" "fmt" "time" @@ -223,11 +224,11 @@ type ReviewImage struct { } type Signal struct { - ID int64 `json:"id"` - TitleID *int64 `json:"title_id"` - RawData []byte `json:"raw_data"` - ProviderID int64 `json:"provider_id"` - Pending bool `json:"pending"` + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` + RawData json.RawMessage `json:"raw_data"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` } type Studio struct { @@ -238,13 +239,13 @@ type Studio struct { } type Tag struct { - ID int64 `json:"id"` - TagNames []byte `json:"tag_names"` + ID int64 `json:"id"` + TagNames json.RawMessage `json:"tag_names"` } type Title struct { ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` + TitleNames json.RawMessage `json:"title_names"` StudioID int64 `json:"studio_id"` PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` @@ -276,10 +277,10 @@ type User struct { } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime time.Time `json:"ctime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 5a1d13c..1cca986 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,9 +7,8 @@ package sqlc import ( "context" + "encoding/json" "time" - - "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -30,6 +29,51 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image return i, err } +const createNewUser = `-- name: CreateNewUser :one +INSERT +INTO users (passhash, nickname) +VALUES ($1, $2) +RETURNING id +` + +type CreateNewUserParams struct { + Passhash string `json:"passhash"` + Nickname string `json:"nickname"` +} + +func (q *Queries) CreateNewUser(ctx context.Context, arg CreateNewUserParams) (int64, error) { + row := q.db.QueryRow(ctx, createNewUser, arg.Passhash, arg.Nickname) + var id int64 + err := row.Scan(&id) + return id, err +} + +const deleteUserTitle = `-- name: DeleteUserTitle :one +DELETE FROM usertitles +WHERE user_id = $1 + AND title_id = $2 +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type DeleteUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` +} + +func (q *Queries) DeleteUserTitle(ctx context.Context, arg DeleteUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, deleteUserTitle, arg.UserID, arg.TitleID) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images @@ -45,40 +89,12 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro const getReviewByID = `-- name: GetReviewByID :one - - SELECT id, data, rating, user_id, title_id, created_at FROM reviews WHERE review_id = $1::bigint ` // 100 is default limit -// -- name: ListTitles :many -// SELECT title_id, title_names, studio_id, poster_id, signal_ids, -// -// title_status, rating, rating_count, release_year, release_season, -// season, episodes_aired, episodes_all, episodes_len -// -// FROM titles -// ORDER BY title_id -// LIMIT $1 OFFSET $2; -// -- name: UpdateTitle :one -// UPDATE titles -// SET -// -// title_names = COALESCE(sqlc.narg('title_names'), title_names), -// studio_id = COALESCE(sqlc.narg('studio_id'), studio_id), -// poster_id = COALESCE(sqlc.narg('poster_id'), poster_id), -// signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids), -// title_status = COALESCE(sqlc.narg('title_status'), title_status), -// release_year = COALESCE(sqlc.narg('release_year'), release_year), -// release_season = COALESCE(sqlc.narg('release_season'), release_season), -// episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired), -// episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all), -// episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len) -// -// WHERE title_id = sqlc.arg('title_id') -// RETURNING *; func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, error) { row := q.db.QueryRow(ctx, getReviewByID, reviewID) var i Review @@ -112,41 +128,60 @@ func (q *Queries) GetStudioByID(ctx context.Context, studioID int64) (Studio, er } const getTitleByID = `-- name: GetTitleByID :one +SELECT + t.id, t.title_names, t.studio_id, t.poster_id, t.title_status, t.rating, t.rating_count, t.release_year, t.release_season, t.season, t.episodes_aired, t.episodes_all, t.episodes_len, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name, + s.illust_id as studio_illust_id, + s.studio_desc as studio_desc, + si.storage_type as studio_storage_type, + si.image_path as studio_image_path +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) +LEFT JOIN images as si ON (s.illust_id = si.id) - - -SELECT id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM titles -WHERE id = $1::bigint +WHERE t.id = $1::bigint +GROUP BY + t.id, i.id, s.id, si.id ` -// -- name: ListUsers :many -// SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -// FROM users -// ORDER BY user_id -// LIMIT $1 OFFSET $2; -// -- name: CreateUser :one -// INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date) -// VALUES ($1, $2, $3, $4, $5, $6, $7) -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: UpdateUser :one -// UPDATE users -// SET -// -// avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), -// disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), -// user_desc = COALESCE(sqlc.narg('user_desc'), user_desc), -// passhash = COALESCE(sqlc.narg('passhash'), passhash) -// -// WHERE user_id = sqlc.arg('user_id') -// RETURNING user_id, avatar_id, nickname, disp_name, user_desc, creation_date; -// -- name: DeleteUser :exec -// DELETE FROM users -// WHERE user_id = $1; -func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error) { +type GetTitleByIDRow struct { + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` + StudioIllustID *int64 `json:"studio_illust_id"` + StudioDesc *string `json:"studio_desc"` + StudioStorageType *StorageTypeT `json:"studio_storage_type"` + StudioImagePath *string `json:"studio_image_path"` +} + +// sqlc.struct: TitlesFull +func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (GetTitleByIDRow, error) { row := q.db.QueryRow(ctx, getTitleByID, titleID) - var i Title + var i GetTitleByIDRow err := row.Scan( &i.ID, &i.TitleNames, @@ -161,6 +196,14 @@ func (q *Queries) GetTitleByID(ctx context.Context, titleID int64) (Title, error &i.EpisodesAired, &i.EpisodesAll, &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, + &i.StudioIllustID, + &i.StudioDesc, + &i.StudioStorageType, + &i.StudioImagePath, ) return i, err } @@ -173,15 +216,15 @@ JOIN title_tags as t ON(t.tag_id = g.id) WHERE t.title_id = $1::bigint ` -func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, error) { +func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([]json.RawMessage, error) { rows, err := q.db.Query(ctx, getTitleTags, titleID) if err != nil { return nil, err } defer rows.Close() - var items [][]byte + items := []json.RawMessage{} for rows.Next() { - var tag_names []byte + var tag_names json.RawMessage if err := rows.Scan(&tag_names); err != nil { return nil, err } @@ -194,19 +237,31 @@ func (q *Queries) GetTitleTags(ctx context.Context, titleID int64) ([][]byte, er } const getUserByID = `-- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1 +SELECT + t.id as id, + t.avatar_id as avatar_id, + t.mail as mail, + t.nickname as nickname, + t.disp_name as disp_name, + t.user_desc as user_desc, + t.creation_date as creation_date, + i.storage_type as storage_type, + i.image_path as image_path +FROM users as t +LEFT JOIN images as i ON (t.avatar_id = i.id) +WHERE t.id = $1::bigint ` type GetUserByIDRow struct { - ID int64 `json:"id"` - AvatarID *int64 `json:"avatar_id"` - Mail *string `json:"mail"` - Nickname string `json:"nickname"` - DispName *string `json:"disp_name"` - UserDesc *string `json:"user_desc"` - CreationDate time.Time `json:"creation_date"` + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + StorageType *StorageTypeT `json:"storage_type"` + ImagePath *string `json:"image_path"` } func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) { @@ -220,6 +275,57 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er &i.DispName, &i.UserDesc, &i.CreationDate, + &i.StorageType, + &i.ImagePath, + ) + return i, err +} + +const getUserByNickname = `-- name: GetUserByNickname :one +SELECT id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date, last_login +FROM users +WHERE nickname = $1 +` + +func (q *Queries) GetUserByNickname(ctx context.Context, nickname string) (User, error) { + row := q.db.QueryRow(ctx, getUserByNickname, nickname) + var i User + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Passhash, + &i.Mail, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.LastLogin, + ) + return i, err +} + +const getUserTitleByID = `-- name: GetUserTitleByID :one +SELECT + ut.user_id, ut.title_id, ut.status, ut.rate, ut.review_id, ut.ctime +FROM usertitles as ut +WHERE ut.title_id = $1::bigint AND ut.user_id = $2::bigint +` + +type GetUserTitleByIDParams struct { + TitleID int64 `json:"title_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) GetUserTitleByID(ctx context.Context, arg GetUserTitleByIDParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, getUserTitleByID, arg.TitleID, arg.UserID) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, ) return i, err } @@ -258,7 +364,7 @@ VALUES ( RETURNING id, tag_names ` -func (q *Queries) InsertTag(ctx context.Context, tagNames []byte) (Tag, error) { +func (q *Queries) InsertTag(ctx context.Context, tagNames json.RawMessage) (Tag, error) { row := q.db.QueryRow(ctx, insertTag, tagNames) var i Tag err := row.Scan(&i.ID, &i.TagNames) @@ -285,94 +391,229 @@ func (q *Queries) InsertTitleTags(ctx context.Context, arg InsertTitleTagsParams return i, err } +const insertUserTitle = `-- name: InsertUserTitle :one +INSERT INTO usertitles (user_id, title_id, status, rate, review_id) +VALUES ( + $1::bigint, + $2::bigint, + $3::usertitle_status_t, + $4::int, + $5::bigint +) +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type InsertUserTitleParams struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` +} + +func (q *Queries) InsertUserTitle(ctx context.Context, arg InsertUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, insertUserTitle, + arg.UserID, + arg.TitleID, + arg.Status, + arg.Rate, + arg.ReviewID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} + const searchTitles = `-- name: SearchTitles :many SELECT - id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM titles -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name + +FROM titles as t +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + CASE + WHEN $1::boolean THEN + -- forward: greater than cursor (next page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year > $3::int) OR + (t.release_year = $3::int AND t.id > $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating > $5::float) OR + (t.rating = $5::float AND t.id > $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id > $4::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $2::text + WHEN 'year' THEN + ($3::int IS NULL) OR + (t.release_year < $3::int) OR + (t.release_year = $3::int AND t.id < $4::bigint) + + WHEN 'rating' THEN + ($5::float IS NULL) OR + (t.rating < $5::float) OR + (t.rating = $5::float AND t.id < $4::bigint) + + WHEN 'id' THEN + ($4::bigint IS NULL) OR + (t.id < $4::bigint) + + ELSE true + END END - AND ($2::title_status_t IS NULL OR title_status = $2::title_status_t) - AND ($3::float IS NULL OR rating >= $3::float) - AND ($4::int IS NULL OR release_year = $4::int) - AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) -ORDER BY - -- Основной ключ: выбранное поле - CASE - WHEN $6::boolean AND $7::text = 'id' THEN id - WHEN $6::boolean AND $7::text = 'year' THEN release_year - WHEN $6::boolean AND $7::text = 'rating' THEN rating - -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END ASC, - CASE - WHEN NOT $6::boolean AND $7::text = 'id' THEN id - WHEN NOT $6::boolean AND $7::text = 'year' THEN release_year - WHEN NOT $6::boolean AND $7::text = 'rating' THEN rating - -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views - END DESC, + AND ( + CASE + WHEN $6::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($6::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) - -- Вторичный ключ: id — только если НЕ сортируем по id - CASE - WHEN $7::text != 'id' AND $6::boolean THEN id - END ASC, - CASE - WHEN $7::text != 'id' AND NOT $6::boolean THEN id - END DESC -LIMIT COALESCE($8::int, 100) + AND ( + $7::title_status_t[] IS NULL + OR array_length($7::title_status_t[], 1) IS NULL + OR array_length($7::title_status_t[], 1) = 0 + OR t.title_status = ANY($7::title_status_t[]) + ) + AND ($8::float IS NULL OR t.rating >= $8::float) + AND ($9::int IS NULL OR t.release_year = $9::int) + AND ($10::release_season_t IS NULL OR t.release_season = $10::release_season_t) + +GROUP BY + t.id, i.id, s.id + +ORDER BY + CASE WHEN $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating + END + END ASC, + CASE WHEN NOT $1::boolean THEN + CASE + WHEN $2::text = 'id' THEN t.id + WHEN $2::text = 'year' THEN t.release_year + WHEN $2::text = 'rating' THEN t.rating + END + END DESC, + + CASE WHEN $2::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE($11::int, 100) ` type SearchTitlesParams struct { + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` Word *string `json:"word"` - Status *TitleStatusT `json:"status"` + TitleStatuses []TitleStatusT `json:"title_statuses"` Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Forward bool `json:"forward"` - SortBy string `json:"sort_by"` Limit *int32 `json:"limit"` } -func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]Title, error) { +type SearchTitlesRow struct { + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` +} + +func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]SearchTitlesRow, error) { rows, err := q.db.Query(ctx, searchTitles, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, arg.Word, - arg.Status, + arg.TitleStatuses, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.Forward, - arg.SortBy, arg.Limit, ) if err != nil { return nil, err } defer rows.Close() - var items []Title + items := []SearchTitlesRow{} for rows.Next() { - var i Title + var i SearchTitlesRow if err := rows.Scan( &i.ID, &i.TitleNames, - &i.StudioID, &i.PosterID, &i.TitleStatus, &i.Rating, @@ -382,7 +623,10 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T &i.Season, &i.EpisodesAired, &i.EpisodesAll, - &i.EpisodesLen, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, ); err != nil { return nil, err } @@ -396,102 +640,217 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T const searchUserTitles = `-- name: SearchUserTitles :many -SELECT - user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len -FROM usertitles as u -JOIN titles as t ON (u.title_id = t.id) -WHERE - CASE - WHEN $1::text IS NOT NULL THEN - ( - SELECT bool_and( - EXISTS ( - SELECT 1 - FROM jsonb_each_text(t.title_names) AS t(key, val) - WHERE val ILIKE pattern - ) - ) - FROM unnest( - ARRAY( - SELECT '%' || trim(w) || '%' - FROM unnest(string_to_array($1::text, ' ')) AS w - WHERE trim(w) <> '' - ) - ) AS pattern - ) - ELSE true +SELECT + t.id as id, + t.title_names as title_names, + t.poster_id as poster_id, + t.title_status as title_status, + t.rating as rating, + t.rating_count as rating_count, + t.release_year as release_year, + t.release_season as release_season, + t.season as season, + t.episodes_aired as episodes_aired, + t.episodes_all as episodes_all, + u.user_id as user_id, + u.status as usertitle_status, + u.rate as user_rate, + u.review_id as review_id, + u.ctime as user_ctime, + i.storage_type as title_storage_type, + i.image_path as title_image_path, + COALESCE( + jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL), + '[]'::jsonb + )::jsonb as tag_names, + s.studio_name as studio_name + +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +LEFT JOIN images as i ON (t.poster_id = i.id) +LEFT JOIN title_tags as tt ON (t.id = tt.title_id) +LEFT JOIN tags as g ON (tt.tag_id = g.id) +LEFT JOIN studios as s ON (t.studio_id = s.id) + +WHERE + u.user_id = $1::bigint + AND + CASE + WHEN $2::boolean THEN + -- forward: greater than cursor (next page) + CASE $3::text + WHEN 'year' THEN + ($4::int IS NULL) OR + (t.release_year > $4::int) OR + (t.release_year = $4::int AND t.id > $5::bigint) + + WHEN 'rating' THEN + ($6::float IS NULL) OR + (t.rating > $6::float) OR + (t.rating = $6::float AND t.id > $5::bigint) + + WHEN 'id' THEN + ($5::bigint IS NULL) OR + (t.id > $5::bigint) + + ELSE true -- fallback + END + + ELSE + -- backward: less than cursor (prev page) + CASE $3::text + WHEN 'year' THEN + ($4::int IS NULL) OR + (t.release_year < $4::int) OR + (t.release_year = $4::int AND t.id < $5::bigint) + + WHEN 'rating' THEN + ($6::float IS NULL) OR + (t.rating < $6::float) OR + (t.rating = $6::float AND t.id < $5::bigint) + + WHEN 'id' THEN + ($5::bigint IS NULL) OR + (t.id < $5::bigint) + + ELSE true + END END - AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) - AND ($3::float IS NULL OR t.rating >= $3::float) - AND ($4::int IS NULL OR t.release_year = $4::int) - AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) - AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) + AND ( + CASE + WHEN $7::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($7::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + ) -LIMIT COALESCE($7::int, 100) + AND ( + $8::title_status_t[] IS NULL + OR array_length($8::title_status_t[], 1) IS NULL + OR array_length($8::title_status_t[], 1) = 0 + OR t.title_status = ANY($8::title_status_t[]) + ) + AND ( + $9::usertitle_status_t[] IS NULL + OR array_length($9::usertitle_status_t[], 1) IS NULL + OR array_length($9::usertitle_status_t[], 1) = 0 + OR u.status = ANY($9::usertitle_status_t[]) + ) + AND ($10::int IS NULL OR u.rate >= $10::int) + AND ($11::float IS NULL OR t.rating >= $11::float) + AND ($12::int IS NULL OR t.release_year = $12::int) + AND ($13::release_season_t IS NULL OR t.release_season = $13::release_season_t) + +GROUP BY + t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id + +ORDER BY + CASE WHEN $2::boolean THEN + CASE + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate + END + END ASC, + CASE WHEN NOT $2::boolean THEN + CASE + WHEN $3::text = 'id' THEN t.id + WHEN $3::text = 'year' THEN t.release_year + WHEN $3::text = 'rating' THEN t.rating + WHEN $3::text = 'rate' THEN u.rate + END + END DESC, + + CASE WHEN $3::text <> 'id' THEN t.id END ASC + +LIMIT COALESCE($14::int, 100) ` type SearchUserTitlesParams struct { - Word *string `json:"word"` - Status *TitleStatusT `json:"status"` - Rating *float64 `json:"rating"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` - Limit *int32 `json:"limit"` + UserID int64 `json:"user_id"` + Forward bool `json:"forward"` + SortBy string `json:"sort_by"` + CursorYear *int32 `json:"cursor_year"` + CursorID *int64 `json:"cursor_id"` + CursorRating *float64 `json:"cursor_rating"` + Word *string `json:"word"` + TitleStatuses []TitleStatusT `json:"title_statuses"` + UsertitleStatuses []UsertitleStatusT `json:"usertitle_statuses"` + Rate *int32 `json:"rate"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Limit *int32 `json:"limit"` } type SearchUserTitlesRow struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int64 `json:"review_id"` - Ctime pgtype.Timestamptz `json:"ctime"` - ID int64 `json:"id"` - TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` - TitleStatus TitleStatusT `json:"title_status"` - Rating *float64 `json:"rating"` - RatingCount *int32 `json:"rating_count"` - ReleaseYear *int32 `json:"release_year"` - ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Season *int32 `json:"season"` - EpisodesAired *int32 `json:"episodes_aired"` - EpisodesAll *int32 `json:"episodes_all"` - EpisodesLen []byte `json:"episodes_len"` + ID int64 `json:"id"` + TitleNames json.RawMessage `json:"title_names"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + UserID int64 `json:"user_id"` + UsertitleStatus UsertitleStatusT `json:"usertitle_status"` + UserRate *int32 `json:"user_rate"` + ReviewID *int64 `json:"review_id"` + UserCtime time.Time `json:"user_ctime"` + TitleStorageType *StorageTypeT `json:"title_storage_type"` + TitleImagePath *string `json:"title_image_path"` + TagNames json.RawMessage `json:"tag_names"` + StudioName *string `json:"studio_name"` } // 100 is default limit -// OFFSET sqlc.narg('offset')::int; func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { rows, err := q.db.Query(ctx, searchUserTitles, + arg.UserID, + arg.Forward, + arg.SortBy, + arg.CursorYear, + arg.CursorID, + arg.CursorRating, arg.Word, - arg.Status, + arg.TitleStatuses, + arg.UsertitleStatuses, + arg.Rate, arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.UsertitleStatus, arg.Limit, ) if err != nil { return nil, err } defer rows.Close() - var items []SearchUserTitlesRow + items := []SearchUserTitlesRow{} for rows.Next() { var i SearchUserTitlesRow if err := rows.Scan( - &i.UserID, - &i.TitleID, - &i.Status, - &i.Rate, - &i.ReviewID, - &i.Ctime, &i.ID, &i.TitleNames, - &i.StudioID, &i.PosterID, &i.TitleStatus, &i.Rating, @@ -501,7 +860,15 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara &i.Season, &i.EpisodesAired, &i.EpisodesAll, - &i.EpisodesLen, + &i.UserID, + &i.UsertitleStatus, + &i.UserRate, + &i.ReviewID, + &i.UserCtime, + &i.TitleStorageType, + &i.TitleImagePath, + &i.TagNames, + &i.StudioName, ); err != nil { return nil, err } @@ -512,3 +879,91 @@ func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesPara } return items, nil } + +const updateUser = `-- name: UpdateUser :one +UPDATE users +SET + avatar_id = COALESCE($1, avatar_id), + disp_name = COALESCE($2, disp_name), + user_desc = COALESCE($3, user_desc), + mail = COALESCE($4, mail) +WHERE id = $5 +RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail +` + +type UpdateUserParams struct { + AvatarID *int64 `json:"avatar_id"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + Mail *string `json:"mail"` + UserID int64 `json:"user_id"` +} + +type UpdateUserRow struct { + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + Mail *string `json:"mail"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) { + row := q.db.QueryRow(ctx, updateUser, + arg.AvatarID, + arg.DispName, + arg.UserDesc, + arg.Mail, + arg.UserID, + ) + var i UpdateUserRow + err := row.Scan( + &i.ID, + &i.AvatarID, + &i.Nickname, + &i.DispName, + &i.UserDesc, + &i.CreationDate, + &i.Mail, + ) + return i, err +} + +const updateUserTitle = `-- name: UpdateUserTitle :one +UPDATE usertitles +SET + status = COALESCE($1::usertitle_status_t, status), + rate = COALESCE($2::int, rate) +WHERE + user_id = $3 + AND title_id = $4 +RETURNING user_id, title_id, status, rate, review_id, ctime +` + +type UpdateUserTitleParams struct { + Status *UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` +} + +// Fails with sql.ErrNoRows if (user_id, title_id) not found +func (q *Queries) UpdateUserTitle(ctx context.Context, arg UpdateUserTitleParams) (Usertitle, error) { + row := q.db.QueryRow(ctx, updateUserTitle, + arg.Status, + arg.Rate, + arg.UserID, + arg.TitleID, + ) + var i Usertitle + err := row.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + ) + return i, err +} diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f74d2ad..904abaf 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -3,6 +3,7 @@ sql: - engine: "postgresql" queries: - "../modules/backend/queries.sql" + - "../modules/auth/queries.sql" schema: "migrations" gen: go: @@ -12,7 +13,20 @@ sql: sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: true emit_pointers_for_null_types: true + emit_empty_slices: true #slices returned by :many queries will be empty instead of nil overrides: + - db_type: "usertitle_status_t" + nullable: true + go_type: + type: "UsertitleStatusT" + pointer: true + - db_type: "storage_type_t" + nullable: true + go_type: + type: "StorageTypeT" + pointer: true + - db_type: "jsonb" + go_type: "encoding/json.RawMessage" - db_type: "uuid" nullable: false go_type: