Compare commits

..

2 commits

Author SHA1 Message Date
6557df5e17
feat: added login page 2025-11-23 03:57:35 +03:00
034487e26a
Merge branch 'auth' into front 2025-11-23 03:35:04 +03:00
63 changed files with 1616 additions and 4865 deletions

View file

@ -18,10 +18,14 @@ jobs:
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version: '^1.25' go-version: '^1.25'
check-latest: false
cache-dependency-path: |
modules/backend/go.sum
- name: Build backend - name: Build Go app
run: | run: |
cd modules/backend cd modules/backend
go mod tidy
go build -o nyanimedb . go build -o nyanimedb .
tar -czvf nyanimedb-backend.tar.gz nyanimedb tar -czvf nyanimedb-backend.tar.gz nyanimedb
@ -31,18 +35,6 @@ jobs:
name: nyanimedb-backend.tar.gz name: nyanimedb-backend.tar.gz
path: modules/backend/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 # Build frontend
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
@ -84,14 +76,6 @@ jobs:
push: true push: true
tags: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest 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 - name: Build and push frontend image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -101,7 +85,7 @@ jobs:
tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest
deploy: deploy:
runs-on: debian-test runs-on: self-hosted
needs: build needs: build
env: env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }} POSTGRES_USER: ${{ secrets.POSTGRES_USER }}

View file

@ -11,57 +11,47 @@ paths:
parameters: parameters:
- $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/cursor'
- $ref: '#/components/parameters/title_sort' - $ref: '#/components/parameters/title_sort'
- name: sort_forward - in: query
in: query name: sort_forward
schema: schema:
type: boolean type: boolean
default: true default: true
- name: ext_search - in: query
in: query name: word
schema:
type: boolean
default: false
- name: word
in: query
schema: schema:
type: string type: string
- name: status - in: query
in: query name: status
description: List of title statuses to filter
schema: schema:
type: array $ref: '#/components/schemas/TitleStatus'
items: - in: query
$ref: '#/components/schemas/TitleStatus' name: rating
explode: false
style: form
- name: rating
in: query
schema: schema:
type: number type: number
format: double format: double
- name: release_year - in: query
in: query name: release_year
schema: schema:
type: integer type: integer
format: int32 format: int32
- name: release_season - in: query
in: query name: release_season
schema: schema:
$ref: '#/components/schemas/ReleaseSeason' $ref: '#/components/schemas/ReleaseSeason'
- name: limit - in: query
in: query name: limit
schema: schema:
type: integer type: integer
format: int32 format: int32
default: 10 default: 10
- name: offset - in: query
in: query name: offset
schema: schema:
type: integer type: integer
format: int32 format: int32
default: 0 default: 0
- name: fields - in: query
in: query name: fields
schema: schema:
type: string type: string
default: all default: all
@ -74,10 +64,10 @@ paths:
type: object type: object
properties: properties:
data: data:
description: List of titles
type: array type: array
items: items:
$ref: '#/components/schemas/Title' $ref: '#/components/schemas/Title'
description: List of titles
cursor: cursor:
$ref: '#/components/schemas/CursorObj' $ref: '#/components/schemas/CursorObj'
required: required:
@ -91,17 +81,16 @@ paths:
description: Unknown server error description: Unknown server error
'/titles/{title_id}': '/titles/{title_id}':
get: get:
operationId: getTitle
summary: Get title description summary: Get title description
parameters: parameters:
- name: title_id - in: path
in: path name: title_id
required: true required: true
schema: schema:
type: integer type: integer
format: int64 format: int64
- name: fields - in: query
in: query name: fields
schema: schema:
type: string type: string
default: all default: all
@ -122,16 +111,15 @@ paths:
description: Unknown server error description: Unknown server error
'/users/{user_id}': '/users/{user_id}':
get: get:
operationId: getUsersId
summary: Get user info summary: Get user info
parameters: parameters:
- name: user_id - in: path
in: path name: user_id
required: true required: true
schema: schema:
type: string type: string
- name: fields - in: query
in: query name: fields
schema: schema:
type: string type: string
default: all default: all
@ -148,146 +136,50 @@ paths:
description: User not found description: User not found
'500': '500':
description: Unknown server error description: Unknown server error
patch: '/users/{user_id}/titles/':
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 `-`, 316 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: get:
operationId: getUserTitles
summary: Get user titles summary: Get user titles
parameters: parameters:
- $ref: '#/components/parameters/cursor' - $ref: '#/components/parameters/cursor'
- $ref: '#/components/parameters/title_sort' - in: path
- name: user_id name: user_id
in: path
required: true required: true
schema: schema:
type: string type: string
- name: sort_forward - in: query
in: query name: word
schema:
type: boolean
default: true
- name: word
in: query
schema: schema:
type: string type: string
- name: status - in: query
in: query name: status
description: List of title statuses to filter
schema: schema:
type: array $ref: '#/components/schemas/TitleStatus'
items: - in: query
$ref: '#/components/schemas/TitleStatus' name: watch_status
explode: false
style: form
- name: watch_status
in: query
schema: schema:
type: array $ref: '#/components/schemas/UserTitleStatus'
items: - in: query
$ref: '#/components/schemas/UserTitleStatus' name: rating
explode: false
style: form
- name: rating
in: query
schema: schema:
type: number type: number
format: double format: double
- name: my_rate - in: query
in: query name: release_year
schema: schema:
type: integer type: integer
format: int32 format: int32
- name: release_year - in: query
in: query name: release_season
schema:
type: integer
format: int32
- name: release_season
in: query
schema: schema:
$ref: '#/components/schemas/ReleaseSeason' $ref: '#/components/schemas/ReleaseSeason'
- name: limit - in: query
in: query name: limit
schema: schema:
type: integer type: integer
format: int32 format: int32
default: 10 default: 10
- name: fields - in: query
in: query name: fields
schema: schema:
type: string type: string
default: all default: all
@ -297,181 +189,15 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: object type: array
properties: items:
data: $ref: '#/components/schemas/UserTitle'
type: array
items:
$ref: '#/components/schemas/UserTitle'
cursor:
$ref: '#/components/schemas/CursorObj'
required:
- data
- cursor
'204': '204':
description: No titles found description: No titles found
'400': '400':
description: Request params are not correct description: Request params are not correct
'404':
description: User not found
'500': '500':
description: Unknown server error 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: components:
parameters: parameters:
cursor: cursor:
@ -487,36 +213,25 @@ components:
schema: schema:
$ref: '#/components/schemas/TitleSort' $ref: '#/components/schemas/TitleSort'
schemas: schemas:
CursorObj:
type: object
required:
- id
properties:
id:
type: integer
format: int64
param:
type: string
TitleSort: TitleSort:
description: Title sort order
type: string type: string
description: Title sort order
default: id default: id
enum: enum:
- id - id
- year - year
- rating - rating
- views - 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: Image:
type: object type: object
properties: properties:
@ -524,11 +239,61 @@ components:
type: integer type: integer
format: int64 format: int64
storage_type: storage_type:
$ref: '#/components/schemas/StorageType' type: string
image_path: image_path:
type: string 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: Studio:
type: object type: object
required:
- id
- name
properties: properties:
id: id:
type: integer type: integer
@ -539,50 +304,21 @@ components:
$ref: '#/components/schemas/Image' $ref: '#/components/schemas/Image'
description: description:
type: string type: string
required:
- id
- 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: Title:
type: object type: object
required:
- id
- title_names
- tags
properties: properties:
id: id:
description: Unique title ID (primary key)
type: integer type: integer
format: int64 format: int64
description: Unique title ID (primary key)
example: 1 example: 1
title_names: title_names:
description: 'Localized titles. Key = language (ISO 639-1), value = list of names'
type: object type: object
example: description: 'Localized titles. Key = language (ISO 639-1), value = list of names'
en:
- Attack on Titan
- AoT
ru:
- Атака титанов
- Титаны
ja:
- 進撃の巨人
additionalProperties: additionalProperties:
type: array type: array
items: items:
@ -592,6 +328,15 @@ components:
example: example:
- Attack on Titan - Attack on Titan
- AoT - AoT
example:
en:
- Attack on Titan
- AoT
ru:
- Атака титанов
- Титаны
ja:
- 進撃の巨人
studio: studio:
$ref: '#/components/schemas/Studio' $ref: '#/components/schemas/Studio'
tags: tags:
@ -622,91 +367,54 @@ components:
additionalProperties: additionalProperties:
type: number type: number
format: double format: double
required: additionalProperties: true
- id
- title_names
- tags
CursorObj:
type: object
properties:
id:
type: integer
format: int64
param:
type: string
required:
- id
User: User:
type: object type: object
properties: properties:
id: id:
description: Unique user ID (primary key)
type: integer type: integer
format: int64 format: int64
description: Unique user ID (primary key)
example: 1 example: 1
image: avatar_id:
$ref: '#/components/schemas/Image' type: integer
format: int64
description: ID of the user avatar (references images table)
example: null
mail: mail:
description: User email
type: string type: string
format: email format: email
description: User email
example: john.doe@example.com example: john.doe@example.com
nickname: nickname:
type: string
description: Username (alphanumeric + _ or -) description: Username (alphanumeric + _ or -)
type: string
example: john_doe_42
maxLength: 16 maxLength: 16
example: john_doe_42
disp_name: disp_name:
type: string
description: Display name description: Display name
type: string
example: John Doe
maxLength: 32 maxLength: 32
example: John Doe
user_desc: user_desc:
description: User description
type: string type: string
example: Just a regular user. description: User description
maxLength: 512 maxLength: 512
example: Just a regular user.
creation_date: creation_date:
description: Timestamp when the user was created
type: string type: string
format: date-time format: date-time
description: Timestamp when the user was created
example: '2025-10-10T23:45:47.908073Z' example: '2025-10-10T23:45:47.908073Z'
required: required:
- user_id - user_id
- nickname - nickname
UserTitleStatus:
description: User's title status
type: string
enum:
- finished
- planned
- dropped
- in-progress
UserTitle: UserTitle:
type: object 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: required:
- user_id - user_id
- title_id - title_id
- status - status
UserTitleMini:
type: object
properties: properties:
user_id: user_id:
type: integer type: integer
@ -725,10 +433,4 @@ components:
ctime: ctime:
type: string type: string
format: date-time format: date-time
required:
- user_id
- title_id
- status
Review:
type: object
additionalProperties: true additionalProperties: true

File diff suppressed because it is too large Load diff

View file

@ -13,11 +13,8 @@ paths:
$ref: "./paths/titles-id.yaml" $ref: "./paths/titles-id.yaml"
/users/{user_id}: /users/{user_id}:
$ref: "./paths/users-id.yaml" $ref: "./paths/users-id.yaml"
/users/{user_id}/titles: /users/{user_id}/titles/:
$ref: "./paths/users-id-titles.yaml" $ref: "./paths/users-id-titles.yaml"
/users/{user_id}/titles/{title_id}:
$ref: "./paths/users-id-titles-id.yaml"
components: components:
parameters: parameters:
$ref: "./parameters/_index.yaml" $ref: "./parameters/_index.yaml"

View file

@ -1,6 +1,5 @@
get: get:
summary: Get title description summary: Get title description
operationId: getTitle
parameters: parameters:
- in: path - in: path
name: title_id name: title_id

View file

@ -8,11 +8,6 @@ get:
schema: schema:
type: boolean type: boolean
default: true default: true
- in: query
name: ext_search
schema:
type: boolean
default: false
- in: query - in: query
name: word name: word
schema: schema:
@ -20,12 +15,7 @@ get:
- in: query - in: query
name: status name: status
schema: schema:
type: array $ref: '../schemas/enums/TitleStatus.yaml'
items:
$ref: '../schemas/enums/TitleStatus.yaml'
description: List of title statuses to filter
style: form
explode: false
- in: query - in: query
name: rating name: rating
schema: schema:

View file

@ -1,107 +0,0 @@
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

View file

@ -1,19 +1,12 @@
get: get:
summary: Get user titles summary: Get user titles
operationId: getUserTitles
parameters: parameters:
- $ref: '../parameters/cursor.yaml' - $ref: '../parameters/cursor.yaml'
- $ref: "../parameters/title_sort.yaml"
- in: path - in: path
name: user_id name: user_id
required: true required: true
schema: schema:
type: string type: string
- in: query
name: sort_forward
schema:
type: boolean
default: true
- in: query - in: query
name: word name: word
schema: schema:
@ -21,30 +14,16 @@ get:
- in: query - in: query
name: status name: status
schema: schema:
type: array $ref: '../schemas/enums/TitleStatus.yaml'
items:
$ref: '../schemas/enums/TitleStatus.yaml'
description: List of title statuses to filter
style: form
explode: false
- in: query - in: query
name: watch_status name: watch_status
schema: schema:
type: array $ref: '../schemas/enums/UserTitleStatus.yaml'
items:
$ref: '../schemas/enums/UserTitleStatus.yaml'
style: form
explode: false
- in: query - in: query
name: rating name: rating
schema: schema:
type: number type: number
format: double format: double
- in: query
name: my_rate
schema:
type: integer
format: int32
- in: query - in: query
name: release_year name: release_year
schema: schema:
@ -71,73 +50,12 @@ get:
content: content:
application/json: application/json:
schema: schema:
type: object type: array
properties: items:
data: $ref: '../schemas/UserTitle.yaml'
type: array
items:
$ref: '../schemas/UserTitle.yaml'
cursor:
$ref: '../schemas/CursorObj.yaml'
required:
- data
- cursor
'204': '204':
description: No titles found description: No titles found
'400': '400':
description: Request params are not correct description: Request params are not correct
'404':
description: User not found
'500': '500':
description: Unknown server error 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

View file

@ -1,6 +1,5 @@
get: get:
summary: Get user info summary: Get user info
operationId: getUsersId
parameters: parameters:
- in: path - in: path
name: user_id name: user_id
@ -25,79 +24,3 @@ get:
description: Request params are not correct description: Request params are not correct
'500': '500':
description: Unknown server error 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 `-`, 316 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

View file

@ -1,10 +1,9 @@
type: object type: object
properties: properties:
# id выпиливаем
id: id:
type: integer type: integer
format: int64 format: int64
storage_type: storage_type:
$ref: './enums/StorageType.yaml' type: string
image_path: image_path:
type: string type: string

View file

@ -3,7 +3,6 @@ required:
- id - id
- name - name
properties: properties:
# id не нужен
id: id:
type: integer type: integer
format: int64 format: int64

View file

@ -60,3 +60,4 @@ properties:
additionalProperties: additionalProperties:
type: number type: number
format: double format: double
additionalProperties: true

View file

@ -5,8 +5,11 @@ properties:
format: int64 format: int64
description: Unique user ID (primary key) description: Unique user ID (primary key)
example: 1 example: 1
image: avatar_id:
$ref: '../schemas/Image.yaml' type: integer
format: int64
description: ID of the user avatar (references images table)
example: null
mail: mail:
type: string type: string
format: email format: email

View file

@ -7,8 +7,9 @@ properties:
user_id: user_id:
type: integer type: integer
format: int64 format: int64
title: title_id:
$ref: ./Title.yaml type: integer
format: int64
status: status:
$ref: ./enums/UserTitleStatus.yaml $ref: ./enums/UserTitleStatus.yaml
rate: rate:
@ -20,3 +21,4 @@ properties:
ctime: ctime:
type: string type: string
format: date-time format: date-time
additionalProperties: true

View file

@ -1,23 +0,0 @@
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

View file

@ -1,5 +1,5 @@
CursorObj: CursorObj:
$ref: "./CursorObj.yaml" $ref: ./CursorObj.yaml
TitleSort: TitleSort:
$ref: "./TitleSort.yaml" $ref: "./TitleSort.yaml"
Image: Image:

View file

@ -1,5 +0,0 @@
type: string
description: Image storage type
enum:
- s3
- local

View file

@ -1,26 +0,0 @@
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.

View file

@ -116,8 +116,9 @@ type PostAuthSignInResponseObject interface {
} }
type PostAuthSignIn200JSONResponse struct { type PostAuthSignIn200JSONResponse struct {
UserId int64 `json:"user_id"` Error *string `json:"error"`
UserName string `json:"user_name"` Success *bool `json:"success,omitempty"`
UserId *string `json:"user_id"`
} }
func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error { func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error {
@ -147,7 +148,9 @@ type PostAuthSignUpResponseObject interface {
} }
type PostAuthSignUp200JSONResponse struct { type PostAuthSignUp200JSONResponse struct {
UserId int64 `json:"user_id"` Error *string `json:"error"`
Success *bool `json:"success,omitempty"`
UserId *string `json:"user_id"`
} }
func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error { func (response PostAuthSignUp200JSONResponse) VisitPostAuthSignUpResponse(w http.ResponseWriter) error {

View file

@ -30,13 +30,16 @@ paths:
content: content:
application/json: application/json:
schema: schema:
required:
- user_id
type: object type: object
properties: properties:
success:
type: boolean
error:
type: string
nullable: true
user_id: user_id:
type: integer type: string
format: int64 nullable: true
/auth/sign-in: /auth/sign-in:
post: post:
@ -56,22 +59,29 @@ paths:
type: string type: string
format: password format: password
responses: responses:
# This one also sets two cookies: access_token and refresh_token
"200": "200":
description: Sign-in result with JWT description: Sign-in result with JWT
# headers:
# Set-Cookie:
# schema:
# type: array
# items:
# type: string
# explode: true
# style: simple
content: content:
application/json: application/json:
schema: schema:
required:
- user_id
- user_name
type: object type: object
properties: properties:
user_id: success:
type: integer type: boolean
format: int64 error:
user_name:
type: string type: string
nullable: true
user_id:
type: string
nullable: true
"401": "401":
description: Access denied due to invalid credentials description: Access denied due to invalid credentials
content: content:

View file

@ -1,4 +0,0 @@
cd ./api
openapi-format .\openapi.yaml --output .\_build\openapi.yaml --yaml
cd ..
oapi-codegen --config=api\oapi-codegen.yaml api\_build\openapi.yaml

View file

@ -11,34 +11,20 @@ services:
- "${POSTGRES_PORT}:5432" - "${POSTGRES_PORT}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql - 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
rabbitmq: # pgadmin:
image: rabbitmq:3-management # image: dpage/pgadmin4:${PGADMIN_VERSION}
container_name: rabbitmq # container_name: pgadmin
ports: # restart: always
- "5672:5672" # environment:
- "15672:15672" # PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
environment: # PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} # ports:
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} # - "${PGADMIN_PORT}:80"
volumes: # depends_on:
- rabbitmq_data:/var/lib/rabbitmq # - postgres
networks: # volumes:
- nyanimedb-network # - pgadmin_data:/var/lib/pgadmin
healthcheck:
test: ["CMD", "rabbitmqctl", "status"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
nyanimedb-backend: nyanimedb-backend:
image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest image: meowgit.nekoea.red/nihonium/nyanimedb-backend:latest
@ -51,23 +37,6 @@ services:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
- postgres - 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: nyanimedb-frontend:
image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest image: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest
@ -77,12 +46,7 @@ services:
- "8081:80" - "8081:80"
depends_on: depends_on:
- nyanimedb-backend - nyanimedb-backend
networks:
- nyanimedb-network
volumes: volumes:
postgres_data: postgres_data:
rabbitmq_data: pgadmin_data:
networks:
nyanimedb-network:

View file

@ -1,3 +1,3 @@
npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios --useUnionTypes npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios
oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml
sqlc generate -f .\sql\sqlc.yaml sqlc generate -f .\sql\sqlc.yaml

2
go.mod
View file

@ -3,7 +3,6 @@ module nyanimedb
go 1.25.0 go 1.25.0
require ( require (
github.com/alexedwards/argon2id v1.0.0
github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
@ -37,7 +36,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect

44
go.sum
View file

@ -1,6 +1,4 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= 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 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= 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= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
@ -72,13 +70,9 @@ 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/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 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 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 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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/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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -93,64 +87,26 @@ 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/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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 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 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 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 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 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 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 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 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-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.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 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -3,21 +3,22 @@ package handlers
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"net/http" "net/http"
auth "nyanimedb/auth" auth "nyanimedb/auth"
sqlc "nyanimedb/sql" sqlc "nyanimedb/sql"
"strconv" "strconv"
"time" "time"
"github.com/alexedwards/argon2id"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"
) )
var accessSecret = []byte("my_access_secret_key") var accessSecret = []byte("my_access_secret_key")
var refreshSecret = []byte("my_refresh_secret_key") var refreshSecret = []byte("my_refresh_secret_key")
var UserDb = make(map[string]string) // TEMP: stores passwords
type Server struct { type Server struct {
db *sqlc.Queries db *sqlc.Queries
} }
@ -31,22 +32,6 @@ func parseInt64(s string) (int32, error) {
return int32(i), err 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) { func generateTokens(userID string) (accessToken string, refreshToken string, err error) {
accessClaims := jwt.MapClaims{ accessClaims := jwt.MapClaims{
"user_id": userID, "user_id": userID,
@ -72,27 +57,19 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err
} }
func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) { func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) {
passhash, err := HashPassword(req.Body.Pass) err := ""
if err != nil { success := true
log.Errorf("failed to hash password: %v", err) UserDb[req.Body.Nickname] = req.Body.Pass
// 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{ return auth.PostAuthSignUp200JSONResponse{
UserId: user_id, Error: &err,
Success: &success,
UserId: &req.Body.Nickname,
}, nil }, nil
} }
func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) { func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInRequestObject) (auth.PostAuthSignInResponseObject, error) {
// ctx.SetCookie("122")
ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context) ginCtx, ok := ctx.Value(gin.ContextKey).(*gin.Context)
if !ok { if !ok {
log.Print("failed to get gin context") log.Print("failed to get gin context")
@ -100,38 +77,28 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque
return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context") return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context")
} }
user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname) err := ""
if err != nil { success := true
log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err)
// TODO: return 400/500
}
ok, err = CheckPassword(req.Body.Pass, user.Passhash) pass, ok := UserDb[req.Body.Nickname]
if err != nil { if !ok || pass != req.Body.Pass {
log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err) e := "invalid credentials"
// TODO: return 500
}
if !ok {
err_msg := "invalid credentials"
return auth.PostAuthSignIn401JSONResponse{ return auth.PostAuthSignIn401JSONResponse{
Error: &err_msg, Error: &e,
}, nil }, nil
} }
accessToken, refreshToken, err := generateTokens(req.Body.Nickname) accessToken, refreshToken, _ := 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.SetSameSite(http.SameSiteStrictMode)
ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true) ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true)
ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true) ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true)
// Return access token; refresh token can be returned in response or HttpOnly cookie
result := auth.PostAuthSignIn200JSONResponse{ result := auth.PostAuthSignIn200JSONResponse{
UserId: user.ID, Error: &err,
UserName: user.Nickname, Success: &success,
UserId: &req.Body.Nickname,
} }
return result, nil return result, nil
} }

View file

@ -1,9 +1,6 @@
package main package main
import ( import (
"context"
"fmt"
"os"
"time" "time"
auth "nyanimedb/auth" auth "nyanimedb/auth"
@ -12,22 +9,14 @@ import (
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
) )
var AppConfig Config var AppConfig Config
func main() { func main() {
// TODO: env args
r := gin.Default() r := gin.Default()
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) var queries *sqlc.Queries = nil
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) server := handlers.NewServer(queries)

View file

@ -1,11 +0,0 @@
-- 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;

View file

@ -1,165 +1,19 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt"
oapi "nyanimedb/api"
"nyanimedb/modules/backend/rmq"
sqlc "nyanimedb/sql" sqlc "nyanimedb/sql"
"strconv" "strconv"
) )
type Handler struct {
publisher *rmq.Publisher
}
func New(publisher *rmq.Publisher) *Handler {
return &Handler{publisher: publisher}
}
type Server struct { type Server struct {
db *sqlc.Queries db *sqlc.Queries
publisher *rmq.Publisher
RPCclient *rmq.RPCClient
} }
func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server { func NewServer(db *sqlc.Queries) Server {
return &Server{ return Server{db: db}
db: db,
publisher: publisher,
RPCclient: rpcclient,
}
} }
func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) { func parseInt64(s string) (int32, 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) i, err := strconv.ParseInt(s, 10, 64)
return i, err return int32(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
} }

View file

@ -1,156 +0,0 @@
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
}

View file

@ -5,10 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
oapi "nyanimedb/api" oapi "nyanimedb/api"
"nyanimedb/modules/backend/rmq"
sqlc "nyanimedb/sql" sqlc "nyanimedb/sql"
"strconv"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -22,18 +19,18 @@ func Word2Sqlc(s *string) *string {
return s return s
} }
func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) { func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) {
if s == nil { if s == nil {
return nil, nil return nil, nil
} }
var t oapi.TitleStatus var t sqlc.TitleStatusT
switch *s { switch *s {
case sqlc.TitleStatusTFinished: case oapi.TitleStatusFinished:
t = oapi.TitleStatusFinished t = sqlc.TitleStatusTFinished
case sqlc.TitleStatusTOngoing: case oapi.TitleStatusOngoing:
t = oapi.TitleStatusOngoing t = sqlc.TitleStatusTOngoing
case sqlc.TitleStatusTPlanned: case oapi.TitleStatusPlanned:
t = oapi.TitleStatusPlanned t = sqlc.TitleStatusTPlanned
default: default:
return nil, fmt.Errorf("unexpected tittle status: %s", *s) return nil, fmt.Errorf("unexpected tittle status: %s", *s)
} }
@ -83,163 +80,170 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro
return oapi_tag_names, nil 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) sqlc_image, err := s.db.GetImageByID(ctx, id)
// if err != nil { if err != nil {
// if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
// return nil, nil //todo: error reference in db return nil, nil //todo: error reference in db
// } }
// return &oapi_image, fmt.Errorf("query GetImageByID: %v", err) return &oapi_image, fmt.Errorf("query GetImageByID: %v", err)
// } }
// //can cast and dont use brain cause all this fields required in image table //can cast and dont use brain cause all this fields required in image table
// oapi_image.Id = &sqlc_image.ID oapi_image.Id = &sqlc_image.ID
// oapi_image.ImagePath = &sqlc_image.ImagePath oapi_image.ImagePath = &sqlc_image.ImagePath
// storageTypeStr := string(sqlc_image.StorageType) storageTypeStr := string(sqlc_image.StorageType)
// oapi_image.StorageType = string(storageTypeStr) oapi_image.StorageType = &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) sqlc_studio, err := s.db.GetStudioByID(ctx, id)
// if err != nil { if err != nil {
// if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
// return nil, nil return nil, nil
// } }
// return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err) return &oapi_studio, fmt.Errorf("query GetStudioByID: %v", err)
// } }
// oapi_studio.Id = sqlc_studio.ID oapi_studio.Id = sqlc_studio.ID
// oapi_studio.Name = sqlc_studio.StudioName oapi_studio.Name = sqlc_studio.StudioName
// oapi_studio.Description = sqlc_studio.StudioDesc oapi_studio.Description = sqlc_studio.StudioDesc
// if sqlc_studio.IllustID == nil { if sqlc_studio.IllustID == nil {
// return &oapi_studio, nil return &oapi_studio, nil
// } }
// oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID) oapi_illust, err := s.GetImage(ctx, *sqlc_studio.IllustID)
// if err != nil { if err != nil {
// return &oapi_studio, fmt.Errorf("GetImage: %v", err) return &oapi_studio, fmt.Errorf("GetImage: %v", err)
// } }
// if oapi_illust != nil { if oapi_illust != nil {
// oapi_studio.Poster = oapi_illust oapi_studio.Poster = oapi_illust
// } }
// return &oapi_studio, nil return &oapi_studio, nil
// } }
func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, error) { 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) {
var oapi_title oapi.Title var oapi_title oapi.Title
sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId) sqlc_title, err := s.db.GetTitleByID(ctx, request.TitleId)
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return oapi.GetTitle204Response{}, nil return oapi.GetTitlesTitleId204Response{}, nil
} }
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitle500Response{}, nil return oapi.GetTitlesTitleId500Response{}, nil
} }
oapi_title, err = s.mapTitle(sqlc_title) oapi_title, err = s.mapTitle(ctx, sqlc_title)
if err != nil { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitle500Response{}, nil return oapi.GetTitlesTitleId500Response{}, nil
} }
return oapi.GetTitle200JSONResponse(oapi_title), nil return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil
} }
func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) {
opai_titles := make([]oapi.Title, 0) opai_titles := make([]oapi.Title, 0)
mqreq := rmq.RabbitRequest{ cursor := oapi.CursorObj{
Timestamp: time.Now(), Id: 1,
} }
word := Word2Sqlc(request.Params.Word) word := Word2Sqlc(request.Params.Word)
if word != nil { status, err := TitleStatus2Sqlc(request.Params.Status)
mqreq.Name = *word if err != nil {
log.Errorf("%v", err)
return oapi.GetTitles400Response{}, err
} }
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason) season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
if err != nil { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitles400Response{}, err return oapi.GetTitles400Response{}, err
} }
if season != nil { // param = nil means it will not be used
mqreq.Season = *request.Params.ReleaseSeason titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{
}
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, Word: word,
TitleStatuses: title_statuses, Status: status,
Rating: request.Params.Rating, Rating: request.Params.Rating,
ReleaseYear: request.Params.ReleaseYear, ReleaseYear: request.Params.ReleaseYear,
ReleaseSeason: season, ReleaseSeason: season,
Forward: true, // default Forward: true,
SortBy: "id", // default SortBy: "id",
Limit: request.Params.Limit, 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), &params)
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 { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitles500Response{}, nil return oapi.GetTitles500Response{}, nil
@ -248,58 +252,15 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje
return oapi.GetTitles204Response{}, nil return oapi.GetTitles204Response{}, nil
} }
var new_cursor oapi.CursorObj
for _, title := range titles { for _, title := range titles {
_title := sqlc.GetTitleByIDRow{ t, err := s.mapTitle(ctx, title)
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 { if err != nil {
log.Errorf("%v", err) log.Errorf("%v", err)
return oapi.GetTitles500Response{}, nil return oapi.GetTitles500Response{}, nil
} }
opai_titles = append(opai_titles, t) 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: new_cursor, Data: opai_titles}, nil return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil
} }

View file

@ -2,486 +2,82 @@ package handlers
import ( import (
"context" "context"
"errors"
"fmt"
oapi "nyanimedb/api" oapi "nyanimedb/api"
sqlc "nyanimedb/sql" sqlc "nyanimedb/sql"
"strconv"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/oapi-codegen/runtime/types" "github.com/oapi-codegen/runtime/types"
log "github.com/sirupsen/logrus"
) )
const ( // type Server struct {
pgErrDuplicateKey = "23505" // db *sqlc.Queries
) // }
func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) { // func NewServer(db *sqlc.Queries) Server {
i := oapi.Image{ // return Server{db: db}
Id: u.AvatarID, // }
ImagePath: u.ImagePath,
} // func parseInt64(s string) (int32, error) {
s, err := sql2StorageType(u.StorageType) // i, err := strconv.ParseInt(s, 10, 64)
if err != nil { // return int32(i), err
return oapi.User{}, fmt.Errorf("mapUser, storage type: %v", err) // }
}
i.StorageType = s func mapUser(u sqlc.GetUserByIDRow) oapi.User {
return oapi.User{ return oapi.User{
Image: &i, AvatarId: u.AvatarID,
CreationDate: &u.CreationDate, CreationDate: &u.CreationDate,
DispName: u.DispName, DispName: u.DispName,
Id: &u.ID, Id: &u.ID,
Mail: StringToEmail(u.Mail), Mail: (*types.Email)(u.Mail),
Nickname: u.Nickname, Nickname: u.Nickname,
UserDesc: u.UserDesc, UserDesc: u.UserDesc,
}, nil }
} }
func (s Server) GetUsersId(ctx context.Context, req oapi.GetUsersIdRequestObject) (oapi.GetUsersIdResponseObject, error) { func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) {
userID, err := parseInt64(req.UserId) userID, err := parseInt64(req.UserId)
if err != nil { if err != nil {
return oapi.GetUsersId404Response{}, nil return oapi.GetUsersUserId404Response{}, nil
} }
_user, err := s.db.GetUserByID(context.TODO(), userID) user, err := s.db.GetUserByID(context.TODO(), int64(userID))
if err != nil { if err != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return oapi.GetUsersId404Response{}, nil return oapi.GetUsersUserId404Response{}, nil
} }
return nil, err return nil, err
} }
user, err := mapUser(_user) return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil
if err != nil {
log.Errorf("%v", err)
return oapi.GetUsersId500Response{}, err
}
return oapi.GetUsersId200JSONResponse(user), nil
} }
func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time { func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) {
if p_date.Valid {
t := p_date.Time var rate int32 = 9
return &t var review_id int64 = 3
time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
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,
},
} }
return nil
} return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil
// 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
// }
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 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), &params)
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
} }

View file

@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
sqlc "nyanimedb/sql" sqlc "nyanimedb/sql"
"os" "os"
"reflect" "reflect"
@ -11,14 +10,11 @@ import (
oapi "nyanimedb/api" oapi "nyanimedb/api"
handlers "nyanimedb/modules/backend/handlers" handlers "nyanimedb/modules/backend/handlers"
"nyanimedb/modules/backend/rmq"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
"github.com/rabbitmq/amqp091-go"
log "github.com/sirupsen/logrus"
) )
var AppConfig Config var AppConfig Config
@ -47,27 +43,12 @@ func main() {
queries := sqlc.New(pool) queries := sqlc.New(pool)
// === RabbitMQ setup === server := handlers.NewServer(queries)
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.LoadHTMLGlob("templates/*")
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production AllowOrigins: []string{"*"}, // allow all origins, change to specific domains in production
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
ExposeHeaders: []string{"Content-Length"}, ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true, AllowCredentials: true,
@ -79,12 +60,24 @@ func main() {
// сюда можно добавить middlewares, если нужно // сюда можно добавить middlewares, если нужно
[]oapi.StrictMiddlewareFunc{}, []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) {
log.Infof("Server starting on :8080") // items := []Item{
if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed { // {ID: 1, Title: "First Item", Description: "This is the description of the first item."},
log.Fatalf("server failed: %v", err) // {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")
} }
func InitConfig() error { func InitConfig() error {

View file

@ -9,19 +9,9 @@ VALUES ($1, $2)
RETURNING id, storage_type, image_path; RETURNING id, storage_type, image_path;
-- name: GetUserByID :one -- name: GetUserByID :one
SELECT SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date
t.id as id, FROM users
t.avatar_id as avatar_id, WHERE id = $1;
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 -- name: GetStudioByID :one
@ -57,347 +47,218 @@ VALUES (
sqlc.arg('tag_names')::jsonb) sqlc.arg('tag_names')::jsonb)
RETURNING id, tag_names; RETURNING id, tag_names;
-- name: UpdateUser :one -- -- name: ListUsers :many
UPDATE users -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date
SET -- FROM users
avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id), -- ORDER BY user_id
disp_name = COALESCE(sqlc.narg('disp_name'), disp_name), -- LIMIT $1 OFFSET $2;
user_desc = COALESCE(sqlc.narg('user_desc'), user_desc),
mail = COALESCE(sqlc.narg('mail'), mail) -- -- name: CreateUser :one
WHERE id = sqlc.arg('user_id') -- INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date)
RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail; -- 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: GetTitleByID :one -- name: GetTitleByID :one
-- sqlc.struct: TitlesFull SELECT *
SELECT FROM titles
t.*, WHERE id = sqlc.arg('title_id')::bigint;
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 -- name: SearchTitles :many
SELECT SELECT
t.id as id, *
t.title_names as title_names, FROM titles
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 WHERE
CASE CASE
WHEN sqlc.arg('forward')::boolean THEN WHEN sqlc.narg('word')::text IS NOT NULL THEN
-- forward: greater than cursor (next page) (
CASE sqlc.arg('sort_by')::text SELECT bool_and(
WHEN 'year' THEN EXISTS (
(sqlc.narg('cursor_year')::int IS NULL) OR SELECT 1
(t.release_year > sqlc.narg('cursor_year')::int) OR FROM jsonb_each_text(title_names) AS t(key, val)
(t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) WHERE val ILIKE pattern
)
WHEN 'rating' THEN )
(sqlc.narg('cursor_rating')::float IS NULL) OR FROM unnest(
(t.rating > sqlc.narg('cursor_rating')::float) OR ARRAY(
(t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) SELECT '%' || trim(w) || '%'
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
WHEN 'id' THEN WHERE trim(w) <> ''
(sqlc.narg('cursor_id')::bigint IS NULL) OR )
(t.id > sqlc.narg('cursor_id')::bigint) ) AS pattern
)
ELSE true -- fallback ELSE true
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 END
AND ( AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t)
CASE AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float)
WHEN sqlc.narg('word')::text IS NOT NULL THEN 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)
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('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, i.id, s.id
ORDER BY ORDER BY
CASE WHEN sqlc.arg('forward')::boolean THEN -- Основной ключ: выбранное поле
CASE CASE
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
END -- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
END ASC, END ASC,
CASE WHEN NOT sqlc.arg('forward')::boolean THEN CASE
CASE WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating -- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
END END DESC,
END DESC,
CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC
-- Вторичный ключ: 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 LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
-- OFFSET sqlc.narg('offset')::int;
-- name: SearchUserTitles :many -- name: SearchUserTitles :many
SELECT SELECT
t.id as id, *
t.title_names as title_names, FROM usertitles as u
t.poster_id as poster_id, JOIN titles as t ON (u.title_id = t.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 WHERE
u.user_id = sqlc.arg('user_id')::bigint
AND
CASE CASE
WHEN sqlc.arg('forward')::boolean THEN WHEN sqlc.narg('word')::text IS NOT NULL THEN
-- forward: greater than cursor (next page) (
CASE sqlc.arg('sort_by')::text SELECT bool_and(
WHEN 'year' THEN EXISTS (
(sqlc.narg('cursor_year')::int IS NULL) OR SELECT 1
(t.release_year > sqlc.narg('cursor_year')::int) OR FROM jsonb_each_text(t.title_names) AS t(key, val)
(t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint) WHERE val ILIKE pattern
)
WHEN 'rating' THEN )
(sqlc.narg('cursor_rating')::float IS NULL) OR FROM unnest(
(t.rating > sqlc.narg('cursor_rating')::float) OR ARRAY(
(t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint) SELECT '%' || trim(w) || '%'
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
WHEN 'id' THEN WHERE trim(w) <> ''
(sqlc.narg('cursor_id')::bigint IS NULL) OR )
(t.id > sqlc.narg('cursor_id')::bigint) ) AS pattern
)
ELSE true -- fallback ELSE true
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 END
AND ( AND (sqlc.narg('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t)
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('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_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('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, 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 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: 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 *;
-- name: GetReviewByID :one -- name: GetReviewByID :one
SELECT * SELECT *
FROM reviews FROM reviews
WHERE review_id = sqlc.arg('review_id')::bigint; WHERE review_id = sqlc.arg('review_id')::bigint;
-- name: InsertUserTitle :one -- -- name: CreateReview :one
INSERT INTO usertitles (user_id, title_id, status, rate, review_id) -- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date)
VALUES ( -- VALUES ($1, $2, $3, $4, $5)
sqlc.arg('user_id')::bigint, -- RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date;
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: UpdateUserTitle :one -- -- name: UpdateReview :one
-- Fails with sql.ErrNoRows if (user_id, title_id) not found -- UPDATE reviews
UPDATE usertitles -- SET
SET -- image_ids = COALESCE(sqlc.narg('image_ids'), image_ids),
status = COALESCE(sqlc.narg('status')::usertitle_status_t, status), -- review_text = COALESCE(sqlc.narg('review_text'), review_text)
rate = COALESCE(sqlc.narg('rate')::int, rate) -- WHERE review_id = sqlc.arg('review_id')
WHERE -- RETURNING *;
user_id = sqlc.arg('user_id')
AND title_id = sqlc.arg('title_id')
RETURNING *;
-- name: DeleteUserTitle :one -- -- name: DeleteReview :exec
DELETE FROM usertitles -- DELETE FROM reviews
WHERE user_id = sqlc.arg('user_id') -- WHERE review_id = $1;
AND title_id = sqlc.arg('title_id')
RETURNING *;
-- name: GetUserTitleByID :one -- name: ListReviewsByTitle :many
SELECT -- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date
ut.* -- FROM reviews
FROM usertitles as ut -- WHERE title_id = $1
WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint; -- 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;

View file

@ -1,261 +0,0 @@
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
}
}
}

View file

@ -19,15 +19,6 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade; 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 404 /404.html;
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;

View file

@ -2,38 +2,22 @@ import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import UserPage from "./pages/UserPage/UserPage"; import UserPage from "./pages/UserPage/UserPage";
import TitlesPage from "./pages/TitlesPage/TitlesPage"; import TitlesPage from "./pages/TitlesPage/TitlesPage";
import TitlePage from "./pages/TitlePage/TitlePage";
import { LoginPage } from "./pages/LoginPage/LoginPage"; import { LoginPage } from "./pages/LoginPage/LoginPage";
import { Header } from "./components/Header/Header"; import { Header } from "./components/Header/Header";
const App: React.FC = () => { const App: React.FC = () => {
const username = localStorage.getItem("username") || undefined; const username = "nihonium";
const userId = localStorage.getItem("userId");
return ( return (
<Router> <Router>
<Header username={username} /> <Header username={username} />
<Routes> <Routes>
{/* auth */} <Route path="/login" element={<LoginPage />} /> {/* <-- маршрут для логина */}
<Route path="/login" element={<LoginPage />} /> <Route path="/signup" element={<LoginPage />} /> {/* <-- можно использовать тот же компонент для регистрации */}
<Route path="/signup" element={<LoginPage />} />
{/*<Route path="/signup" element={<LoginPage />} />*/}
{/* users */}
{/*<Route path="/users" element={<UsersPage />} />*/}
<Route path="/users/:id" element={<UserPage />} /> <Route path="/users/:id" element={<UserPage />} />
<Route
path="/profile"
element={userId ? <UserPage userId={userId} /> : <LoginPage />}
/>
{/* titles */}
<Route path="/titles" element={<TitlesPage />} /> <Route path="/titles" element={<TitlesPage />} />
<Route path="/titles/:id" element={<TitlePage />} />
</Routes> </Routes>
</Router> </Router>
); );
}; };
export default App; export default App;

View file

@ -20,7 +20,7 @@ export type OpenAPIConfig = {
}; };
export const OpenAPI: OpenAPIConfig = { export const OpenAPI: OpenAPIConfig = {
BASE: '/api/v1', BASE: 'http://10.1.0.65:8081/api/v1',
VERSION: '1.0.0', VERSION: '1.0.0',
WITH_CREDENTIALS: false, WITH_CREDENTIALS: false,
CREDENTIALS: 'include', CREDENTIALS: 'include',

View file

@ -12,7 +12,6 @@ export type { CursorObj } from './models/CursorObj';
export type { Image } from './models/Image'; export type { Image } from './models/Image';
export type { ReleaseSeason } from './models/ReleaseSeason'; export type { ReleaseSeason } from './models/ReleaseSeason';
export type { Review } from './models/Review'; export type { Review } from './models/Review';
export type { StorageType } from './models/StorageType';
export type { Studio } from './models/Studio'; export type { Studio } from './models/Studio';
export type { Tag } from './models/Tag'; export type { Tag } from './models/Tag';
export type { Tags } from './models/Tags'; export type { Tags } from './models/Tags';
@ -22,7 +21,6 @@ export type { TitleSort } from './models/TitleSort';
export type { TitleStatus } from './models/TitleStatus'; export type { TitleStatus } from './models/TitleStatus';
export type { User } from './models/User'; export type { User } from './models/User';
export type { UserTitle } from './models/UserTitle'; export type { UserTitle } from './models/UserTitle';
export type { UserTitleMini } from './models/UserTitleMini';
export type { UserTitleStatus } from './models/UserTitleStatus'; export type { UserTitleStatus } from './models/UserTitleStatus';
export { DefaultService } from './services/DefaultService'; export { DefaultService } from './services/DefaultService';

View file

@ -2,10 +2,9 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { StorageType } from './StorageType';
export type Image = { export type Image = {
id?: number; id?: number;
storage_type?: StorageType; storage_type?: string;
image_path?: string; image_path?: string;
}; };

View file

@ -1,8 +0,0 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* Image storage type
*/
export type StorageType = 's3' | 'local';

View file

@ -2,30 +2,4 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { Image } from './Image'; export type Title = Record<string, any>;
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<string, Array<string>>;
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<string, number>;
};

View file

@ -2,13 +2,15 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { Image } from './Image';
export type User = { export type User = {
/** /**
* Unique user ID (primary key) * Unique user ID (primary key)
*/ */
id?: number; id?: number;
image?: Image; /**
* ID of the user avatar (references images table)
*/
avatar_id?: number;
/** /**
* User email * User email
*/ */

View file

@ -2,14 +2,4 @@
/* istanbul ignore file */ /* istanbul ignore file */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { Title } from './Title'; export type UserTitle = Record<string, any>;
import type { UserTitleStatus } from './UserTitleStatus';
export type UserTitle = {
user_id: number;
title?: Title;
status: UserTitleStatus;
rate?: number;
review_id?: number;
ctime?: string;
};

View file

@ -1,14 +0,0 @@
/* 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;
};

View file

@ -9,7 +9,6 @@ import type { TitleSort } from '../models/TitleSort';
import type { TitleStatus } from '../models/TitleStatus'; import type { TitleStatus } from '../models/TitleStatus';
import type { User } from '../models/User'; import type { User } from '../models/User';
import type { UserTitle } from '../models/UserTitle'; import type { UserTitle } from '../models/UserTitle';
import type { UserTitleMini } from '../models/UserTitleMini';
import type { UserTitleStatus } from '../models/UserTitleStatus'; import type { UserTitleStatus } from '../models/UserTitleStatus';
import type { CancelablePromise } from '../core/CancelablePromise'; import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI'; import { OpenAPI } from '../core/OpenAPI';
@ -21,7 +20,7 @@ export class DefaultService {
* @param sort * @param sort
* @param sortForward * @param sortForward
* @param word * @param word
* @param status List of title statuses to filter * @param status
* @param rating * @param rating
* @param releaseYear * @param releaseYear
* @param releaseSeason * @param releaseSeason
@ -36,7 +35,7 @@ export class DefaultService {
sort?: TitleSort, sort?: TitleSort,
sortForward: boolean = true, sortForward: boolean = true,
word?: string, word?: string,
status?: Array<TitleStatus>, status?: TitleStatus,
rating?: number, rating?: number,
releaseYear?: number, releaseYear?: number,
releaseSeason?: ReleaseSeason, releaseSeason?: ReleaseSeason,
@ -79,7 +78,7 @@ export class DefaultService {
* @returns Title Title description * @returns Title Title description
* @throws ApiError * @throws ApiError
*/ */
public static getTitle( public static getTitles1(
titleId: number, titleId: number,
fields: string = 'all', fields: string = 'all',
): CancelablePromise<Title> { ): CancelablePromise<Title> {
@ -106,7 +105,7 @@ export class DefaultService {
* @returns User User info * @returns User User info
* @throws ApiError * @throws ApiError
*/ */
public static getUsersId( public static getUsers(
userId: string, userId: string,
fields: string = 'all', fields: string = 'all',
): CancelablePromise<User> { ): CancelablePromise<User> {
@ -126,112 +125,45 @@ 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 `-`, 316 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 * Get user titles
* @param userId * @param userId
* @param cursor * @param cursor
* @param sort
* @param sortForward
* @param word * @param word
* @param status List of title statuses to filter * @param status
* @param watchStatus * @param watchStatus
* @param rating * @param rating
* @param myRate
* @param releaseYear * @param releaseYear
* @param releaseSeason * @param releaseSeason
* @param limit * @param limit
* @param fields * @param fields
* @returns any List of user titles * @returns UserTitle List of user titles
* @throws ApiError * @throws ApiError
*/ */
public static getUserTitles( public static getUsersTitles(
userId: string, userId: string,
cursor?: string, cursor?: string,
sort?: TitleSort,
sortForward: boolean = true,
word?: string, word?: string,
status?: Array<TitleStatus>, status?: TitleStatus,
watchStatus?: Array<UserTitleStatus>, watchStatus?: UserTitleStatus,
rating?: number, rating?: number,
myRate?: number,
releaseYear?: number, releaseYear?: number,
releaseSeason?: ReleaseSeason, releaseSeason?: ReleaseSeason,
limit: number = 10, limit: number = 10,
fields: string = 'all', fields: string = 'all',
): CancelablePromise<{ ): CancelablePromise<Array<UserTitle>> {
data: Array<UserTitle>;
cursor: CursorObj;
}> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'GET', method: 'GET',
url: '/users/{user_id}/titles', url: '/users/{user_id}/titles/',
path: { path: {
'user_id': userId, 'user_id': userId,
}, },
query: { query: {
'cursor': cursor, 'cursor': cursor,
'sort': sort,
'sort_forward': sortForward,
'word': word, 'word': word,
'status': status, 'status': status,
'watch_status': watchStatus, 'watch_status': watchStatus,
'rating': rating, 'rating': rating,
'my_rate': myRate,
'release_year': releaseYear, 'release_year': releaseYear,
'release_season': releaseSeason, 'release_season': releaseSeason,
'limit': limit, 'limit': limit,
@ -239,130 +171,8 @@ export class DefaultService {
}, },
errors: { errors: {
400: `Request params are not correct`, 400: `Request params are not correct`,
404: `User not found`,
500: `Unknown server error`, 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`,
},
});
}
} }

View file

@ -20,7 +20,7 @@ export type OpenAPIConfig = {
}; };
export const OpenAPI: OpenAPIConfig = { export const OpenAPI: OpenAPIConfig = {
BASE: '/auth', BASE: 'http://127.0.0.1:8082',
VERSION: '1.0.0', VERSION: '1.0.0',
WITH_CREDENTIALS: false, WITH_CREDENTIALS: false,
CREDENTIALS: 'include', CREDENTIALS: 'include',

View file

@ -41,9 +41,9 @@ export class AuthService {
pass: string; pass: string;
}, },
): CancelablePromise<{ ): CancelablePromise<{
success?: boolean;
error?: string | null; error?: string | null;
user_id?: string | null; user_id?: string | null;
user_name?: string | null;
}> { }> {
return __request(OpenAPI, { return __request(OpenAPI, {
method: 'POST', method: 'POST',

View file

@ -12,7 +12,7 @@ export const Header: React.FC<HeaderProps> = ({ username }) => {
const toggleMenu = () => setMenuOpen(!menuOpen); const toggleMenu = () => setMenuOpen(!menuOpen);
return ( return (
<header className="w-full bg-white shadow-md sticky top-0 left-0 z-50"> <header className="w-full bg-white shadow-md fixed top-0 left-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center"> <div className="flex justify-between h-16 items-center">

View file

@ -1,88 +0,0 @@
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>
);
}

View file

@ -1,22 +0,0 @@
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>
);
}

View file

@ -1,22 +0,0 @@
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>
);
}

View file

@ -18,19 +18,17 @@ export const LoginPage: React.FC = () => {
try { try {
if (isLogin) { if (isLogin) {
const res = await AuthService.postAuthSignIn({ nickname, pass: password }); const res = await AuthService.postAuthSignIn({ nickname, pass: password });
if (res.user_id && res.user_name) { if (res.success) {
// Сохраняем user_id и username в localStorage // TODO: сохранить JWT в localStorage/cookie
localStorage.setItem("userId", res.user_id); console.log("Logged in user id:", res.user_id);
localStorage.setItem("username", res.user_name); navigate("/"); // редирект после успешного входа
navigate("/profile"); // редирект на профиль
} else { } else {
setError(res.error || "Login failed"); setError(res.error || "Login failed");
} }
} else { } else {
// SignUp оставляем без сохранения данных
const res = await AuthService.postAuthSignUp({ nickname, pass: password }); const res = await AuthService.postAuthSignUp({ nickname, pass: password });
if (res.user_id) { if (res.success) {
console.log("User signed up with id:", res.user_id);
setIsLogin(true); // переключаемся на login после регистрации setIsLogin(true); // переключаемся на login после регистрации
} else { } else {
setError(res.error || "Sign up failed"); setError(res.error || "Sign up failed");

View file

@ -1,108 +1,64 @@
import { useEffect, useState } from "react"; // import React, { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom"; // import { useParams } from "react-router-dom";
import { DefaultService } from "../../api/services/DefaultService"; // import { DefaultService } from "../../api/services/DefaultService";
import type { Title } from "../../api"; // import type { User } from "../../api/models/User";
import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls"; // import styles from "./UserPage.module.css";
export default function TitlePage() { // const UserPage: React.FC = () => {
const params = useParams(); // const { id } = useParams<{ id: string }>();
const titleId = Number(params.id); // const [user, setUser] = useState<User | null>(null);
// const [loading, setLoading] = useState(true);
// const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState<Title | null>(null); // useEffect(() => {
const [loading, setLoading] = useState(true); // if (!id) return;
const [error, setError] = useState<string | null>(null);
// --------------------------- // const getTitleInfo = async () => {
// LOAD TITLE INFO // try {
// --------------------------- // const userInfo = await DefaultService.getTitle(id, "all");
useEffect(() => { // setUser(userInfo);
const fetchTitle = async () => { // } catch (err) {
setLoading(true); // console.error(err);
try { // setError("Failed to fetch user info.");
const data = await DefaultService.getTitle(titleId, "all"); // } finally {
setTitle(data); // setLoading(false);
setError(null); // }
} catch (err: any) { // };
console.error(err); // getTitleInfo();
setError(err?.message || "Failed to fetch title"); // }, [id]);
} finally {
setLoading(false);
}
};
fetchTitle();
}, [titleId]);
const getTagsString = () => // if (loading) return <div className={styles.loader}>Loading...</div>;
title?.tags?.map(tag => tag.en).filter(Boolean).join(", "); // if (error) return <div className={styles.error}>{error}</div>;
// if (!user) return <div className={styles.error}>User not found.</div>;
if (loading) return <div className="mt-20 font-medium text-black">Loading title...</div>; // return (
if (error) return <div className="mt-20 text-red-600 font-medium">{error}</div>; // <div className={styles.container}>
if (!title) return null; // <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>
return ( // <div className={styles.info}>
<div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center"> // <h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
<div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6"> // <p className={styles.nickname}>@{user.nickname}</p>
{/* Poster + status buttons */} // {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
<div className="flex flex-col items-center"> // <p className={styles.created}>
<img // Joined: {new Date(user.creation_date).toLocaleDateString()}
src={title.poster?.image_path || "/default-poster.png"} // </p>
alt={title.title_names?.en?.[0] || "Title poster"} // </div>
className="w-48 h-72 object-cover rounded-lg mb-4" // </div>
/> // </div>
// );
// };
{/* Status buttons */} // export default UserPage;
<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>
);
}

View file

@ -0,0 +1 @@
@import "tailwindcss";

View file

@ -7,7 +7,6 @@ import { TitleCardSquare } from "../../components/cards/TitleCardSquare";
import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal"; import { TitleCardHorizontal } from "../../components/cards/TitleCardHorizontal";
import type { CursorObj, Title, TitleSort } from "../../api"; import type { CursorObj, Title, TitleSort } from "../../api";
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch"; import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
import { Link } from "react-router-dom";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@ -136,11 +135,11 @@ const handleLoadMore = async () => {
hasMore={!!cursor || nextPage.length > 1} hasMore={!!cursor || nextPage.length > 1}
loadingMore={loadingMore} loadingMore={loadingMore}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
renderItem={(title, layout) => ( renderItem={(title, layout) =>
<Link to={`/titles/${title.id}`} key={title.id} className="block"> layout === "square"
{layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} />} ? <TitleCardSquare title={title} />
</Link> : <TitleCardHorizontal title={title} />
)} }
/> />
{!cursor && nextPage.length == 0 && ( {!cursor && nextPage.length == 0 && (

View file

@ -0,0 +1,103 @@
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;
}

View file

@ -1,184 +1,67 @@
// pages/UserPage/UserPage.tsx import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; // <-- import
import { useParams } from "react-router-dom";
import { DefaultService } from "../../api/services/DefaultService"; import { DefaultService } from "../../api/services/DefaultService";
import { SearchBar } from "../../components/SearchBar/SearchBar"; import type { User } from "../../api/models/User";
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox"; import styles from "./UserPage.module.css";
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 PAGE_SIZE = 10;
type UserPageProps = {
userId?: string;
};
export default function UserPage({ userId }: UserPageProps) {
const params = useParams();
const id = userId || params?.id;
const UserPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); // <-- get user id from URL
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loadingUser, setLoadingUser] = useState(true); const [loading, setLoading] = useState(true);
const [errorUser, setErrorUser] = useState<string | null>(null); const [error, setError] = 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(() => { useEffect(() => {
const fetchUser = async () => { if (!id) return;
if (!id) return;
setLoadingUser(true); const getUserInfo = async () => {
try { try {
const result = await DefaultService.getUsersId(id, "all"); const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id
setUser(result); setUser(userInfo);
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) { } catch (err) {
console.error(err); console.error(err);
setError("Failed to fetch user info.");
} finally {
setLoading(false);
} }
} };
getUserInfo();
}, [id]);
setLoadingMore(false); 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 ( return (
<div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center"> <div className={styles.container}>
<div className={styles.header}>
{/* --- Карточка пользователя --- */} <div className={styles.avatarWrapper}>
{loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>} {user.avatar_id ? (
{errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>} <img
{user && ( src={`/images/${user.avatar_id}.png`}
<div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8"> alt="User Avatar"
<img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" /> className={styles.avatarImg}
<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>} <div className={styles.avatarPlaceholder}>
{user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>} {user.disp_name?.[0] || "U"}
</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>
)} )}
</> </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> </div>
); );
} };
export default UserPage;

View file

@ -1,3 +1,6 @@
-- TODO:
-- maybe jsonb constraints
-- clean unused images
CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress');
CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE storage_type_t AS ENUM ('local', 's3');
CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned');
@ -21,24 +24,37 @@ CREATE TABLE images (
image_path text UNIQUE NOT NULL 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 ( CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
avatar_id bigint REFERENCES images (id) ON DELETE SET NULL, avatar_id bigint REFERENCES images (id),
passhash text NOT NULL, passhash text NOT NULL,
mail text CHECK (mail ~ '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+$'), 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,}$'), nickname text UNIQUE NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]{3,}$'),
disp_name text, disp_name text,
user_desc text, user_desc text,
creation_date timestamptz NOT NULL DEFAULT NOW(), creation_date timestamptz NOT NULL,
last_login timestamptz last_login timestamptz
); );
CREATE TABLE studios ( CREATE TABLE studios (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
studio_name text NOT NULL UNIQUE, studio_name text NOT NULL UNIQUE,
illust_id bigint REFERENCES images (id) ON DELETE SET NULL, illust_id bigint REFERENCES images (id),
studio_desc text studio_desc text
); );
@ -48,7 +64,7 @@ CREATE TABLE titles (
-- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]}
title_names jsonb NOT NULL, title_names jsonb NOT NULL,
studio_id bigint NOT NULL REFERENCES studios (id), studio_id bigint NOT NULL REFERENCES studios (id),
poster_id bigint REFERENCES images (id) ON DELETE SET NULL, poster_id bigint REFERENCES images (id),
title_status title_status_t NOT NULL, title_status title_status_t NOT NULL,
rating float CHECK (rating >= 0 AND rating <= 10), rating float CHECK (rating >= 0 AND rating <= 10),
rating_count int CHECK (rating_count >= 0), rating_count int CHECK (rating_count >= 0),
@ -64,36 +80,21 @@ CREATE TABLE titles (
AND episodes_aired <= episodes_all)) 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 ( CREATE TABLE usertitles (
PRIMARY KEY (user_id, title_id), PRIMARY KEY (user_id, title_id),
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, user_id bigint NOT NULL REFERENCES users (id),
title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, title_id bigint NOT NULL REFERENCES titles (id),
status usertitle_status_t NOT NULL, status usertitle_status_t NOT NULL,
rate int CHECK (rate > 0 AND rate <= 10), rate int CHECK (rate > 0 AND rate <= 10),
review_id bigint REFERENCES reviews (id) ON DELETE SET NULL, review_id bigint REFERENCES reviews (id),
ctime timestamptz NOT NULL DEFAULT now() ctime timestamptz
-- // TODO: series status -- // TODO: series status
); );
CREATE TABLE title_tags ( CREATE TABLE title_tags (
PRIMARY KEY (title_id, tag_id), PRIMARY KEY (title_id, tag_id),
title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE, title_id bigint NOT NULL REFERENCES titles (id),
tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE tag_id bigint NOT NULL REFERENCES tags (id)
); );
CREATE TABLE signals ( CREATE TABLE signals (
@ -104,17 +105,17 @@ CREATE TABLE signals (
pending boolean NOT NULL 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 ( CREATE TABLE external_ids (
user_id bigint NOT NULL REFERENCES users (id), user_id bigint NOT NULL REFERENCES users (id),
service_id bigint REFERENCES external_services (id), service_id bigint REFERENCES external_services (id),
external_id text NOT NULL external_id text NOT NULL
); );
CREATE TABLE external_services (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text UNIQUE NOT NULL
);
-- Functions -- Functions
CREATE OR REPLACE FUNCTION update_title_rating() CREATE OR REPLACE FUNCTION update_title_rating()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
@ -169,16 +170,3 @@ CREATE TRIGGER trg_notify_new_signal
AFTER INSERT ON signals AFTER INSERT ON signals
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION notify_new_signal(); 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();

View file

@ -6,7 +6,6 @@ package sqlc
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json"
"fmt" "fmt"
"time" "time"
@ -224,11 +223,11 @@ type ReviewImage struct {
} }
type Signal struct { type Signal struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TitleID *int64 `json:"title_id"` TitleID *int64 `json:"title_id"`
RawData json.RawMessage `json:"raw_data"` RawData []byte `json:"raw_data"`
ProviderID int64 `json:"provider_id"` ProviderID int64 `json:"provider_id"`
Pending bool `json:"pending"` Pending bool `json:"pending"`
} }
type Studio struct { type Studio struct {
@ -239,13 +238,13 @@ type Studio struct {
} }
type Tag struct { type Tag struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TagNames json.RawMessage `json:"tag_names"` TagNames []byte `json:"tag_names"`
} }
type Title struct { type Title struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TitleNames json.RawMessage `json:"title_names"` TitleNames []byte `json:"title_names"`
StudioID int64 `json:"studio_id"` StudioID int64 `json:"studio_id"`
PosterID *int64 `json:"poster_id"` PosterID *int64 `json:"poster_id"`
TitleStatus TitleStatusT `json:"title_status"` TitleStatus TitleStatusT `json:"title_status"`
@ -277,10 +276,10 @@ type User struct {
} }
type Usertitle struct { type Usertitle struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
TitleID int64 `json:"title_id"` TitleID int64 `json:"title_id"`
Status UsertitleStatusT `json:"status"` Status UsertitleStatusT `json:"status"`
Rate *int32 `json:"rate"` Rate *int32 `json:"rate"`
ReviewID *int64 `json:"review_id"` ReviewID *int64 `json:"review_id"`
Ctime time.Time `json:"ctime"` Ctime pgtype.Timestamptz `json:"ctime"`
} }

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@ sql:
- engine: "postgresql" - engine: "postgresql"
queries: queries:
- "../modules/backend/queries.sql" - "../modules/backend/queries.sql"
- "../modules/auth/queries.sql"
schema: "migrations" schema: "migrations"
gen: gen:
go: go:
@ -13,20 +12,7 @@ sql:
sql_driver: "github.com/jackc/pgx/v5" sql_driver: "github.com/jackc/pgx/v5"
emit_json_tags: true emit_json_tags: true
emit_pointers_for_null_types: true emit_pointers_for_null_types: true
emit_empty_slices: true #slices returned by :many queries will be empty instead of nil
overrides: 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" - db_type: "uuid"
nullable: false nullable: false
go_type: go_type: