Compare commits
96 commits
6557df5e17
...
ab29c33f5b
| Author | SHA1 | Date | |
|---|---|---|---|
| ab29c33f5b | |||
| a29aefbe97 | |||
| 1308e265a6 | |||
| 77a63a1c74 | |||
| c6cebb0ed2 | |||
| 1756d61da4 | |||
| f71c1f4f08 | |||
| f843c23e57 | |||
| 37cdc32d5d | |||
| 8a3e14a5e5 | |||
| 3f0456ba01 | |||
| 13342d5613 | |||
| 1c9de1c402 | |||
| 497e4039ec | |||
| f1e61aee2d | |||
| ad1c567b42 | |||
| de22dbfb50 | |||
| 98178731b9 | |||
| 9338c65040 | |||
| 40e0b14f2a | |||
| 3528ea7d34 | |||
| 6cbf0afb33 | |||
| a25a912ead | |||
| 451df61127 | |||
| 246fdc86b5 | |||
| 658d666fec | |||
| f2589e05e8 | |||
| 79e8ece948 | |||
| e98d2c6509 | |||
| 4c74315291 | |||
| 4c643d80bb | |||
| 68294dd13c | |||
| e0a68d7d0f | |||
| cb9fba6fbc | |||
| 759679990a | |||
| 65b76d58c3 | |||
| 51bf7b6f7e | |||
| 9139c77c5f | |||
| b8bfe01ef5 | |||
| 0cda597001 | |||
| dbdb52269a | |||
| 354c577f7d | |||
| 87eb6a6b12 | |||
| 3aafab36c2 | |||
| a225d1fb60 | |||
| 673ce48fac | |||
| 4c7d03cddc | |||
| 9f74c9eeb6 | |||
| ed79c71273 | |||
| f3fa41382a | |||
| cea7cd3cd8 | |||
| 76df4d8592 | |||
| 15a681c622 | |||
| e999534b3f | |||
| cfb2523cfd | |||
| 1d65833b8a | |||
| 08cedd65ce | |||
| 20e9c5bf23 | |||
| ea43e13df4 | |||
| 4e732e1542 | |||
| 843dfb24b9 | |||
| b42fb34903 | |||
| 17ebba47c9 | |||
| d1937fcbd7 | |||
| 02f7685eaa | |||
| 15095d572d | |||
| e792d5780b | |||
| 0c94930bca | |||
| 0942df1fa4 | |||
| c500116916 | |||
| 69e8a8dc79 | |||
| 2929a6e4bc | |||
| cbbc2c179d | |||
| b400f22844 | |||
| ff84b00526 | |||
| d0c3547ef6 | |||
| 8427288daf | |||
| 4b1ac9177d | |||
| f1ba15d3a4 | |||
| 6a39d89ef9 | |||
| 6fa2ff2eb8 | |||
| 32566fe7a2 | |||
| 79485e04c0 | |||
| ba68b5ee04 | |||
| 82767e0b4c | |||
| 258eb749d9 | |||
| aaade55a58 | |||
| c289e0a45e | |||
| 89a05492c3 | |||
| 86e3df2205 | |||
| 870bbe2395 | |||
| 73547187be | |||
| 1f5196c015 | |||
| e16adcf6cd | |||
| 8d98fb0cf8 | |||
| af0492cdf1 |
63 changed files with 4876 additions and 1627 deletions
|
|
@ -18,14 +18,10 @@ 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 Go app
|
- name: Build backend
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -35,6 +31,18 @@ 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:
|
||||||
|
|
@ -76,6 +84,14 @@ 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:
|
||||||
|
|
@ -85,7 +101,7 @@ jobs:
|
||||||
tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest
|
tags: meowgit.nekoea.red/nihonium/nyanimedb-frontend:latest
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: self-hosted
|
runs-on: debian-test
|
||||||
needs: build
|
needs: build
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
|
|
|
||||||
|
|
@ -11,47 +11,57 @@ paths:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/cursor'
|
- $ref: '#/components/parameters/cursor'
|
||||||
- $ref: '#/components/parameters/title_sort'
|
- $ref: '#/components/parameters/title_sort'
|
||||||
- in: query
|
- name: sort_forward
|
||||||
name: sort_forward
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
- in: query
|
- name: ext_search
|
||||||
name: word
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
- name: word
|
||||||
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- in: query
|
- name: status
|
||||||
name: status
|
in: query
|
||||||
|
description: List of title statuses to filter
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/TitleStatus'
|
type: array
|
||||||
- in: query
|
items:
|
||||||
name: rating
|
$ref: '#/components/schemas/TitleStatus'
|
||||||
|
explode: false
|
||||||
|
style: form
|
||||||
|
- name: rating
|
||||||
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
- in: query
|
- name: release_year
|
||||||
name: release_year
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
- in: query
|
- name: release_season
|
||||||
name: release_season
|
in: query
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ReleaseSeason'
|
$ref: '#/components/schemas/ReleaseSeason'
|
||||||
- in: query
|
- name: limit
|
||||||
name: limit
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
default: 10
|
default: 10
|
||||||
- in: query
|
- name: offset
|
||||||
name: offset
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
default: 0
|
default: 0
|
||||||
- in: query
|
- name: fields
|
||||||
name: fields
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
default: all
|
default: all
|
||||||
|
|
@ -64,10 +74,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:
|
||||||
|
|
@ -81,16 +91,17 @@ 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:
|
||||||
- in: path
|
- name: title_id
|
||||||
name: title_id
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
- in: query
|
- name: fields
|
||||||
name: fields
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
default: all
|
default: all
|
||||||
|
|
@ -111,15 +122,16 @@ 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:
|
||||||
- in: path
|
- name: user_id
|
||||||
name: user_id
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- in: query
|
- name: fields
|
||||||
name: fields
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
default: all
|
default: all
|
||||||
|
|
@ -136,50 +148,146 @@ paths:
|
||||||
description: User not found
|
description: User not found
|
||||||
'500':
|
'500':
|
||||||
description: Unknown server error
|
description: Unknown server error
|
||||||
'/users/{user_id}/titles/':
|
patch:
|
||||||
|
operationId: updateUser
|
||||||
|
summary: Partially update a user account
|
||||||
|
description: |
|
||||||
|
Update selected user profile fields (excluding password).
|
||||||
|
Password updates must be done via the dedicated auth-service (`/auth/`).
|
||||||
|
Fields not provided in the request body remain unchanged.
|
||||||
|
parameters:
|
||||||
|
- name: user_id
|
||||||
|
in: path
|
||||||
|
description: User ID (primary key)
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
example: 123
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
description: Only provided fields are updated. Omitted fields remain unchanged.
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
avatar_id:
|
||||||
|
description: ID of the user avatar (references `images.id`); set to `null` to remove avatar
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
example: 42
|
||||||
|
nullable: true
|
||||||
|
mail:
|
||||||
|
description: User email (must be unique and valid)
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
example: john.doe.updated@example.com
|
||||||
|
pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$'
|
||||||
|
nickname:
|
||||||
|
description: 'Username (alphanumeric + `_` or `-`, 3–16 chars)'
|
||||||
|
type: string
|
||||||
|
example: john_doe_43
|
||||||
|
maxLength: 16
|
||||||
|
minLength: 3
|
||||||
|
pattern: '^[a-zA-Z0-9_-]{3,16}$'
|
||||||
|
disp_name:
|
||||||
|
description: Display name
|
||||||
|
type: string
|
||||||
|
example: John Smith
|
||||||
|
maxLength: 32
|
||||||
|
user_desc:
|
||||||
|
description: User description / bio
|
||||||
|
type: string
|
||||||
|
example: Just a curious developer.
|
||||||
|
maxLength: 512
|
||||||
|
additionalProperties: false
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User updated successfully. Returns updated user representation (excluding sensitive fields).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
'400':
|
||||||
|
description: 'Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized — missing or invalid authentication token
|
||||||
|
'403':
|
||||||
|
description: 'Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)'
|
||||||
|
'404':
|
||||||
|
description: User not found
|
||||||
|
'409':
|
||||||
|
description: 'Conflict — e.g., requested `nickname` or `mail` already taken by another user'
|
||||||
|
'422':
|
||||||
|
description: 'Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)'
|
||||||
|
'500':
|
||||||
|
description: Unknown server error
|
||||||
|
'/users/{user_id}/titles':
|
||||||
get:
|
get:
|
||||||
|
operationId: getUserTitles
|
||||||
summary: Get user titles
|
summary: Get user titles
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/cursor'
|
- $ref: '#/components/parameters/cursor'
|
||||||
- in: path
|
- $ref: '#/components/parameters/title_sort'
|
||||||
name: user_id
|
- name: user_id
|
||||||
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- in: query
|
- name: sort_forward
|
||||||
name: word
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
- name: word
|
||||||
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- in: query
|
- name: status
|
||||||
name: status
|
in: query
|
||||||
|
description: List of title statuses to filter
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/TitleStatus'
|
type: array
|
||||||
- in: query
|
items:
|
||||||
name: watch_status
|
$ref: '#/components/schemas/TitleStatus'
|
||||||
|
explode: false
|
||||||
|
style: form
|
||||||
|
- name: watch_status
|
||||||
|
in: query
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/UserTitleStatus'
|
type: array
|
||||||
- in: query
|
items:
|
||||||
name: rating
|
$ref: '#/components/schemas/UserTitleStatus'
|
||||||
|
explode: false
|
||||||
|
style: form
|
||||||
|
- name: rating
|
||||||
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
- in: query
|
- name: my_rate
|
||||||
name: release_year
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
- in: query
|
- name: release_year
|
||||||
name: release_season
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
- name: release_season
|
||||||
|
in: query
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ReleaseSeason'
|
$ref: '#/components/schemas/ReleaseSeason'
|
||||||
- in: query
|
- name: limit
|
||||||
name: limit
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
default: 10
|
default: 10
|
||||||
- in: query
|
- name: fields
|
||||||
name: fields
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
default: all
|
default: all
|
||||||
|
|
@ -189,15 +297,181 @@ paths:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: object
|
||||||
items:
|
properties:
|
||||||
$ref: '#/components/schemas/UserTitle'
|
data:
|
||||||
|
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:
|
||||||
|
|
@ -213,25 +487,36 @@ 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:
|
||||||
type: string
|
|
||||||
description: Title sort order
|
description: Title sort order
|
||||||
|
type: string
|
||||||
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:
|
||||||
|
|
@ -239,61 +524,11 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
storage_type:
|
storage_type:
|
||||||
type: string
|
$ref: '#/components/schemas/StorageType'
|
||||||
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
|
||||||
|
|
@ -304,30 +539,41 @@ components:
|
||||||
$ref: '#/components/schemas/Image'
|
$ref: '#/components/schemas/Image'
|
||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
Title:
|
|
||||||
type: object
|
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- title_names
|
- name
|
||||||
- tags
|
Tag:
|
||||||
|
description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names'
|
||||||
|
type: object
|
||||||
|
example:
|
||||||
|
en: Shojo
|
||||||
|
ru: Сёдзё
|
||||||
|
ja: 少女
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
Tags:
|
||||||
|
description: Array of localized tags
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Tag'
|
||||||
|
example:
|
||||||
|
- en: Shojo
|
||||||
|
ru: Сёдзё
|
||||||
|
ja: 少女
|
||||||
|
- en: Shounen
|
||||||
|
ru: Сёнен
|
||||||
|
ja: 少年
|
||||||
|
Title:
|
||||||
|
type: object
|
||||||
properties:
|
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:
|
||||||
type: object
|
|
||||||
description: 'Localized titles. Key = language (ISO 639-1), value = list of names'
|
description: 'Localized titles. Key = language (ISO 639-1), value = list of names'
|
||||||
additionalProperties:
|
type: object
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
example: Attack on Titan
|
|
||||||
minItems: 1
|
|
||||||
example:
|
|
||||||
- Attack on Titan
|
|
||||||
- AoT
|
|
||||||
example:
|
example:
|
||||||
en:
|
en:
|
||||||
- Attack on Titan
|
- Attack on Titan
|
||||||
|
|
@ -337,6 +583,15 @@ components:
|
||||||
- Титаны
|
- Титаны
|
||||||
ja:
|
ja:
|
||||||
- 進撃の巨人
|
- 進撃の巨人
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: Attack on Titan
|
||||||
|
minItems: 1
|
||||||
|
example:
|
||||||
|
- Attack on Titan
|
||||||
|
- AoT
|
||||||
studio:
|
studio:
|
||||||
$ref: '#/components/schemas/Studio'
|
$ref: '#/components/schemas/Studio'
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -367,54 +622,91 @@ components:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
additionalProperties: true
|
required:
|
||||||
User:
|
- id
|
||||||
|
- title_names
|
||||||
|
- tags
|
||||||
|
CursorObj:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
param:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
description: Unique user ID (primary key)
|
description: Unique user ID (primary key)
|
||||||
example: 1
|
|
||||||
avatar_id:
|
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: ID of the user avatar (references images table)
|
example: 1
|
||||||
example: null
|
image:
|
||||||
|
$ref: '#/components/schemas/Image'
|
||||||
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 -)
|
||||||
maxLength: 16
|
type: string
|
||||||
example: john_doe_42
|
example: john_doe_42
|
||||||
|
maxLength: 16
|
||||||
disp_name:
|
disp_name:
|
||||||
type: string
|
|
||||||
description: Display name
|
description: Display name
|
||||||
maxLength: 32
|
|
||||||
example: John Doe
|
|
||||||
user_desc:
|
|
||||||
type: string
|
type: string
|
||||||
|
example: John Doe
|
||||||
|
maxLength: 32
|
||||||
|
user_desc:
|
||||||
description: User description
|
description: User description
|
||||||
maxLength: 512
|
type: string
|
||||||
example: Just a regular user.
|
example: Just a regular user.
|
||||||
|
maxLength: 512
|
||||||
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
|
||||||
|
|
@ -433,4 +725,10 @@ 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
|
||||||
|
|
|
||||||
1353
api/api.gen.go
1353
api/api.gen.go
File diff suppressed because it is too large
Load diff
|
|
@ -13,8 +13,11 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
get:
|
get:
|
||||||
summary: Get title description
|
summary: Get title description
|
||||||
|
operationId: getTitle
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: title_id
|
name: title_id
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ 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:
|
||||||
|
|
@ -15,7 +20,12 @@ get:
|
||||||
- in: query
|
- in: query
|
||||||
name: status
|
name: status
|
||||||
schema:
|
schema:
|
||||||
$ref: '../schemas/enums/TitleStatus.yaml'
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '../schemas/enums/TitleStatus.yaml'
|
||||||
|
description: List of title statuses to filter
|
||||||
|
style: form
|
||||||
|
explode: false
|
||||||
- in: query
|
- in: query
|
||||||
name: rating
|
name: rating
|
||||||
schema:
|
schema:
|
||||||
|
|
|
||||||
107
api/paths/users-id-titles-id.yaml
Normal file
107
api/paths/users-id-titles-id.yaml
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
get:
|
||||||
|
summary: Get user title
|
||||||
|
operationId: getUserTitle
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: user_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
- in: path
|
||||||
|
name: title_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User titles
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/UserTitleMini.yaml'
|
||||||
|
'204':
|
||||||
|
description: No user title found
|
||||||
|
'400':
|
||||||
|
description: Request params are not correct
|
||||||
|
'404':
|
||||||
|
description: User or title not found
|
||||||
|
'500':
|
||||||
|
description: Unknown server error
|
||||||
|
|
||||||
|
patch:
|
||||||
|
summary: Update a usertitle
|
||||||
|
description: User updating title list of watched
|
||||||
|
operationId: updateUserTitle
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: user_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
- in: path
|
||||||
|
name: title_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
$ref: '../schemas/enums/UserTitleStatus.yaml'
|
||||||
|
rate:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Title successfully updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/UserTitleMini.yaml'
|
||||||
|
'400':
|
||||||
|
description: Invalid request body (missing fields, invalid types, etc.)
|
||||||
|
'401':
|
||||||
|
description: Unauthorized — missing or invalid auth token
|
||||||
|
'403':
|
||||||
|
description: Forbidden — user not allowed to update title
|
||||||
|
'404':
|
||||||
|
description: User or Title not found
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
|
||||||
|
delete:
|
||||||
|
summary: Delete a usertitle
|
||||||
|
description: User deleting title from list of watched
|
||||||
|
operationId: deleteUserTitle
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: user_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
- in: path
|
||||||
|
name: title_id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Title successfully deleted
|
||||||
|
'401':
|
||||||
|
description: Unauthorized — missing or invalid auth token
|
||||||
|
'403':
|
||||||
|
description: Forbidden — user not allowed to delete title
|
||||||
|
'404':
|
||||||
|
description: User or Title not found
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
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:
|
||||||
|
|
@ -14,16 +21,30 @@ get:
|
||||||
- in: query
|
- in: query
|
||||||
name: status
|
name: status
|
||||||
schema:
|
schema:
|
||||||
$ref: '../schemas/enums/TitleStatus.yaml'
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '../schemas/enums/TitleStatus.yaml'
|
||||||
|
description: List of title statuses to filter
|
||||||
|
style: form
|
||||||
|
explode: false
|
||||||
- in: query
|
- in: query
|
||||||
name: watch_status
|
name: watch_status
|
||||||
schema:
|
schema:
|
||||||
$ref: '../schemas/enums/UserTitleStatus.yaml'
|
type: array
|
||||||
|
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:
|
||||||
|
|
@ -50,12 +71,73 @@ get:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: object
|
||||||
items:
|
properties:
|
||||||
$ref: '../schemas/UserTitle.yaml'
|
data:
|
||||||
|
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
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
get:
|
get:
|
||||||
summary: Get user info
|
summary: Get user info
|
||||||
|
operationId: getUsersId
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: user_id
|
name: user_id
|
||||||
|
|
@ -24,3 +25,79 @@ 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 `-`, 3–16 chars)
|
||||||
|
maxLength: 16
|
||||||
|
minLength: 3
|
||||||
|
example: john_doe_43
|
||||||
|
disp_name:
|
||||||
|
type: string
|
||||||
|
description: Display name
|
||||||
|
maxLength: 32
|
||||||
|
example: John Smith
|
||||||
|
user_desc:
|
||||||
|
type: string
|
||||||
|
description: User description / bio
|
||||||
|
maxLength: 512
|
||||||
|
example: Just a curious developer.
|
||||||
|
additionalProperties: false
|
||||||
|
description: Only provided fields are updated. Omitted fields remain unchanged.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User updated successfully. Returns updated user representation (excluding sensitive fields).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/User.yaml'
|
||||||
|
'400':
|
||||||
|
description: Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)
|
||||||
|
'401':
|
||||||
|
description: Unauthorized — missing or invalid authentication token
|
||||||
|
'403':
|
||||||
|
description: Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)
|
||||||
|
'404':
|
||||||
|
description: User not found
|
||||||
|
'409':
|
||||||
|
description: Conflict — e.g., requested `nickname` or `mail` already taken by another user
|
||||||
|
'422':
|
||||||
|
description: Unprocessable Entity — semantic errors not caught by schema (e.g., invalid `avatar_id`)
|
||||||
|
'500':
|
||||||
|
description: Unknown server error
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id:
|
# id выпиливаем
|
||||||
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
storage_type:
|
storage_type:
|
||||||
type: string
|
$ref: './enums/StorageType.yaml'
|
||||||
image_path:
|
image_path:
|
||||||
type: string
|
type: string
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ required:
|
||||||
- id
|
- id
|
||||||
- name
|
- name
|
||||||
properties:
|
properties:
|
||||||
|
# id не нужен
|
||||||
id:
|
id:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
|
|
||||||
|
|
@ -60,4 +60,3 @@ properties:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: number
|
type: number
|
||||||
format: double
|
format: double
|
||||||
additionalProperties: true
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,8 @@ properties:
|
||||||
format: int64
|
format: int64
|
||||||
description: Unique user ID (primary key)
|
description: Unique user ID (primary key)
|
||||||
example: 1
|
example: 1
|
||||||
avatar_id:
|
image:
|
||||||
type: integer
|
$ref: '../schemas/Image.yaml'
|
||||||
format: int64
|
|
||||||
description: ID of the user avatar (references images table)
|
|
||||||
example: null
|
|
||||||
mail:
|
mail:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ properties:
|
||||||
user_id:
|
user_id:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
title_id:
|
title:
|
||||||
type: integer
|
$ref: ./Title.yaml
|
||||||
format: int64
|
|
||||||
status:
|
status:
|
||||||
$ref: ./enums/UserTitleStatus.yaml
|
$ref: ./enums/UserTitleStatus.yaml
|
||||||
rate:
|
rate:
|
||||||
|
|
@ -21,4 +20,3 @@ properties:
|
||||||
ctime:
|
ctime:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
additionalProperties: true
|
|
||||||
|
|
|
||||||
23
api/schemas/UserTitleMini.yaml
Normal file
23
api/schemas/UserTitleMini.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- user_id
|
||||||
|
- title_id
|
||||||
|
- status
|
||||||
|
properties:
|
||||||
|
user_id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
title_id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
status:
|
||||||
|
$ref: ./enums/UserTitleStatus.yaml
|
||||||
|
rate:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
review_id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
ctime:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
CursorObj:
|
CursorObj:
|
||||||
$ref: ./CursorObj.yaml
|
$ref: "./CursorObj.yaml"
|
||||||
TitleSort:
|
TitleSort:
|
||||||
$ref: "./TitleSort.yaml"
|
$ref: "./TitleSort.yaml"
|
||||||
Image:
|
Image:
|
||||||
|
|
|
||||||
5
api/schemas/enums/StorageType.yaml
Normal file
5
api/schemas/enums/StorageType.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
type: string
|
||||||
|
description: Image storage type
|
||||||
|
enum:
|
||||||
|
- s3
|
||||||
|
- local
|
||||||
26
api/schemas/updateUser.yaml
Normal file
26
api/schemas/updateUser.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
avatar_id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
nullable: true
|
||||||
|
description: ID of the user avatar (references `images.id`); set to `null` to remove avatar
|
||||||
|
example: 42
|
||||||
|
mail:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
pattern: '^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\\.[a-zA-Z0-9_-]+$'
|
||||||
|
description: User email (must be unique and valid)
|
||||||
|
example: john.doe.updated@example.com
|
||||||
|
disp_name:
|
||||||
|
type: string
|
||||||
|
description: Display name
|
||||||
|
maxLength: 32
|
||||||
|
example: John Smith
|
||||||
|
user_desc:
|
||||||
|
type: string
|
||||||
|
description: User description / bio
|
||||||
|
maxLength: 512
|
||||||
|
example: Just a curious developer.
|
||||||
|
additionalProperties: false
|
||||||
|
description: Only provided fields are updated. Omitted fields remain unchanged.
|
||||||
|
|
@ -116,9 +116,8 @@ type PostAuthSignInResponseObject interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostAuthSignIn200JSONResponse struct {
|
type PostAuthSignIn200JSONResponse struct {
|
||||||
Error *string `json:"error"`
|
UserId int64 `json:"user_id"`
|
||||||
Success *bool `json:"success,omitempty"`
|
UserName string `json:"user_name"`
|
||||||
UserId *string `json:"user_id"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error {
|
func (response PostAuthSignIn200JSONResponse) VisitPostAuthSignInResponse(w http.ResponseWriter) error {
|
||||||
|
|
@ -148,9 +147,7 @@ type PostAuthSignUpResponseObject interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostAuthSignUp200JSONResponse struct {
|
type PostAuthSignUp200JSONResponse struct {
|
||||||
Error *string `json:"error"`
|
UserId int64 `json:"user_id"`
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,13 @@ 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: string
|
type: integer
|
||||||
nullable: true
|
format: int64
|
||||||
|
|
||||||
/auth/sign-in:
|
/auth/sign-in:
|
||||||
post:
|
post:
|
||||||
|
|
@ -59,29 +56,22 @@ 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:
|
||||||
success:
|
|
||||||
type: boolean
|
|
||||||
error:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
user_id:
|
user_id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
user_name:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
|
||||||
"401":
|
"401":
|
||||||
description: Access denied due to invalid credentials
|
description: Access denied due to invalid credentials
|
||||||
content:
|
content:
|
||||||
|
|
|
||||||
4
deploy/api_gen.ps1
Normal file
4
deploy/api_gen.ps1
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
cd ./api
|
||||||
|
openapi-format .\openapi.yaml --output .\_build\openapi.yaml --yaml
|
||||||
|
cd ..
|
||||||
|
oapi-codegen --config=api\oapi-codegen.yaml api\_build\openapi.yaml
|
||||||
|
|
@ -11,20 +11,34 @@ 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
|
||||||
|
|
||||||
# pgadmin:
|
rabbitmq:
|
||||||
# image: dpage/pgadmin4:${PGADMIN_VERSION}
|
image: rabbitmq:3-management
|
||||||
# container_name: pgadmin
|
container_name: rabbitmq
|
||||||
# restart: always
|
ports:
|
||||||
# environment:
|
- "5672:5672"
|
||||||
# PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
|
- "15672:15672"
|
||||||
# PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
|
environment:
|
||||||
# ports:
|
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
|
||||||
# - "${PGADMIN_PORT}:80"
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
|
||||||
# depends_on:
|
volumes:
|
||||||
# - postgres
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
# volumes:
|
networks:
|
||||||
# - pgadmin_data:/var/lib/pgadmin
|
- nyanimedb-network
|
||||||
|
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
|
||||||
|
|
@ -37,6 +51,23 @@ 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
|
||||||
|
|
@ -46,7 +77,12 @@ services:
|
||||||
- "8081:80"
|
- "8081:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- nyanimedb-backend
|
- nyanimedb-backend
|
||||||
|
networks:
|
||||||
|
- nyanimedb-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
pgadmin_data:
|
rabbitmq_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nyanimedb-network:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios
|
npx openapi-typescript-codegen --input ..\..\api\openapi.yaml --output ./src/api --client axios --useUnionTypes
|
||||||
oapi-codegen --config=api/oapi-codegen.yaml .\api\openapi.yaml
|
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
2
go.mod
|
|
@ -3,6 +3,7 @@ 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
|
||||||
|
|
@ -36,6 +37,7 @@ 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
44
go.sum
|
|
@ -1,4 +1,6 @@
|
||||||
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=
|
||||||
|
|
@ -70,9 +72,13 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/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=
|
||||||
|
|
@ -87,26 +93,64 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/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=
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +31,22 @@ 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,
|
||||||
|
|
@ -57,19 +72,27 @@ func generateTokens(userID string) (accessToken string, refreshToken string, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) {
|
func (s Server) PostAuthSignUp(ctx context.Context, req auth.PostAuthSignUpRequestObject) (auth.PostAuthSignUpResponseObject, error) {
|
||||||
err := ""
|
passhash, err := HashPassword(req.Body.Pass)
|
||||||
success := true
|
if err != nil {
|
||||||
UserDb[req.Body.Nickname] = req.Body.Pass
|
log.Errorf("failed to hash password: %v", err)
|
||||||
|
// TODO: return 500
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id, err := s.db.CreateNewUser(context.Background(), sqlc.CreateNewUserParams{
|
||||||
|
Passhash: passhash,
|
||||||
|
Nickname: req.Body.Nickname,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to create user %s: %v", req.Body.Nickname, err)
|
||||||
|
// TODO: check err and retyrn 400/500
|
||||||
|
}
|
||||||
|
|
||||||
return auth.PostAuthSignUp200JSONResponse{
|
return auth.PostAuthSignUp200JSONResponse{
|
||||||
Error: &err,
|
UserId: user_id,
|
||||||
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")
|
||||||
|
|
@ -77,28 +100,38 @@ func (s Server) PostAuthSignIn(ctx context.Context, req auth.PostAuthSignInReque
|
||||||
return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context")
|
return auth.PostAuthSignIn200JSONResponse{}, fmt.Errorf("failed to get gin.Context from context.Context")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := ""
|
user, err := s.db.GetUserByNickname(context.Background(), req.Body.Nickname)
|
||||||
success := true
|
if err != nil {
|
||||||
|
log.Errorf("failed to get user by nickname %s: %v", req.Body.Nickname, err)
|
||||||
|
// TODO: return 400/500
|
||||||
|
}
|
||||||
|
|
||||||
pass, ok := UserDb[req.Body.Nickname]
|
ok, err = CheckPassword(req.Body.Pass, user.Passhash)
|
||||||
if !ok || pass != req.Body.Pass {
|
if err != nil {
|
||||||
e := "invalid credentials"
|
log.Errorf("failed to check password for user %s: %v", req.Body.Nickname, err)
|
||||||
|
// TODO: return 500
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
err_msg := "invalid credentials"
|
||||||
return auth.PostAuthSignIn401JSONResponse{
|
return auth.PostAuthSignIn401JSONResponse{
|
||||||
Error: &e,
|
Error: &err_msg,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, refreshToken, _ := generateTokens(req.Body.Nickname)
|
accessToken, refreshToken, err := generateTokens(req.Body.Nickname)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to generate tokens for user %s: %v", req.Body.Nickname, err)
|
||||||
|
// TODO: return 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check cookie settings carefully
|
||||||
ginCtx.SetSameSite(http.SameSiteStrictMode)
|
ginCtx.SetSameSite(http.SameSiteStrictMode)
|
||||||
ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", true, true)
|
ginCtx.SetCookie("access_token", accessToken, 604800, "/auth", "", false, true)
|
||||||
ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", true, true)
|
ginCtx.SetCookie("refresh_token", refreshToken, 604800, "/api", "", false, true)
|
||||||
|
|
||||||
// Return access token; refresh token can be returned in response or HttpOnly cookie
|
|
||||||
result := auth.PostAuthSignIn200JSONResponse{
|
result := auth.PostAuthSignIn200JSONResponse{
|
||||||
Error: &err,
|
UserId: user.ID,
|
||||||
Success: &success,
|
UserName: user.Nickname,
|
||||||
UserId: &req.Body.Nickname,
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
auth "nyanimedb/auth"
|
auth "nyanimedb/auth"
|
||||||
|
|
@ -9,14 +12,22 @@ 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()
|
||||||
|
|
||||||
var queries *sqlc.Queries = nil
|
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var queries *sqlc.Queries = sqlc.New(pool)
|
||||||
|
|
||||||
server := handlers.NewServer(queries)
|
server := handlers.NewServer(queries)
|
||||||
|
|
||||||
|
|
|
||||||
11
modules/auth/queries.sql
Normal file
11
modules/auth/queries.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- name: GetUserByNickname :one
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE nickname = sqlc.arg('nickname');
|
||||||
|
|
||||||
|
-- name: CreateNewUser :one
|
||||||
|
INSERT
|
||||||
|
INTO users (passhash, nickname)
|
||||||
|
VALUES (sqlc.arg(passhash), sqlc.arg(nickname))
|
||||||
|
RETURNING id;
|
||||||
|
|
||||||
|
|
@ -1,19 +1,165 @@
|
||||||
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) Server {
|
func NewServer(db *sqlc.Queries, publisher *rmq.Publisher, rpcclient *rmq.RPCClient) *Server {
|
||||||
return Server{db: db}
|
return &Server{
|
||||||
|
db: db,
|
||||||
|
publisher: publisher,
|
||||||
|
RPCclient: rpcclient,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseInt64(s string) (int32, error) {
|
func sql2StorageType(s *sqlc.StorageTypeT) (*oapi.StorageType, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var t oapi.StorageType
|
||||||
|
switch *s {
|
||||||
|
case sqlc.StorageTypeTLocal:
|
||||||
|
t = oapi.Local
|
||||||
|
case sqlc.StorageTypeTS3:
|
||||||
|
t = oapi.S3
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected storage type: %s", *s)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Server) mapTitle(title sqlc.GetTitleByIDRow) (oapi.Title, error) {
|
||||||
|
|
||||||
|
oapi_title := oapi.Title{
|
||||||
|
EpisodesAired: title.EpisodesAired,
|
||||||
|
EpisodesAll: title.EpisodesAll,
|
||||||
|
// EpisodesLen: &episodes_lens,
|
||||||
|
Id: title.ID,
|
||||||
|
// Poster: &oapi_image,
|
||||||
|
Rating: title.Rating,
|
||||||
|
RatingCount: title.RatingCount,
|
||||||
|
// ReleaseSeason: &release_season,
|
||||||
|
ReleaseYear: title.ReleaseYear,
|
||||||
|
// Studio: &oapi_studio,
|
||||||
|
// Tags: oapi_tag_names,
|
||||||
|
// TitleNames: title_names,
|
||||||
|
// TitleStatus: oapi_status,
|
||||||
|
// AdditionalProperties:
|
||||||
|
}
|
||||||
|
|
||||||
|
title_names := make(map[string][]string, 0)
|
||||||
|
err := json.Unmarshal(title.TitleNames, &title_names)
|
||||||
|
if err != nil {
|
||||||
|
return oapi.Title{}, fmt.Errorf("unmarshal TitleNames: %v", err)
|
||||||
|
}
|
||||||
|
oapi_title.TitleNames = title_names
|
||||||
|
|
||||||
|
if len(title.EpisodesLen) > 0 {
|
||||||
|
episodes_lens := make(map[string]float64, 0)
|
||||||
|
err = json.Unmarshal(title.EpisodesLen, &episodes_lens)
|
||||||
|
if err != nil {
|
||||||
|
return oapi.Title{}, fmt.Errorf("unmarshal EpisodesLen: %v", err)
|
||||||
|
}
|
||||||
|
oapi_title.EpisodesLen = &episodes_lens
|
||||||
|
}
|
||||||
|
|
||||||
|
oapi_tag_names := make(oapi.Tags, 0)
|
||||||
|
err = json.Unmarshal(title.TagNames, &oapi_tag_names)
|
||||||
|
if err != nil {
|
||||||
|
return oapi.Title{}, fmt.Errorf("unmarshalling title_tag: %v", err)
|
||||||
|
}
|
||||||
|
oapi_title.Tags = oapi_tag_names
|
||||||
|
|
||||||
|
var oapi_studio oapi.Studio
|
||||||
|
if title.StudioName != nil {
|
||||||
|
oapi_studio.Name = *title.StudioName
|
||||||
|
}
|
||||||
|
if title.StudioID != 0 {
|
||||||
|
oapi_studio.Id = title.StudioID
|
||||||
|
oapi_studio.Description = title.StudioDesc
|
||||||
|
if title.StudioIllustID != nil {
|
||||||
|
oapi_studio.Poster = &oapi.Image{}
|
||||||
|
oapi_studio.Poster.Id = title.StudioIllustID
|
||||||
|
oapi_studio.Poster.ImagePath = title.StudioImagePath
|
||||||
|
|
||||||
|
s, err := sql2StorageType(title.StudioStorageType)
|
||||||
|
if err != nil {
|
||||||
|
return oapi.Title{}, fmt.Errorf("mapTitle, studio storage type: %v", err)
|
||||||
|
}
|
||||||
|
oapi_studio.Poster.StorageType = s
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oapi_title.Studio = &oapi_studio
|
||||||
|
|
||||||
|
var oapi_image oapi.Image
|
||||||
|
|
||||||
|
if title.PosterID != nil {
|
||||||
|
oapi_image.Id = title.PosterID
|
||||||
|
oapi_image.ImagePath = title.TitleImagePath
|
||||||
|
s, err := sql2StorageType(title.TitleStorageType)
|
||||||
|
if err != nil {
|
||||||
|
return oapi.Title{}, fmt.Errorf("mapTitle, title starage type: %v", err)
|
||||||
|
}
|
||||||
|
oapi_image.StorageType = s
|
||||||
|
}
|
||||||
|
oapi_title.Poster = &oapi_image
|
||||||
|
|
||||||
|
var release_season oapi.ReleaseSeason
|
||||||
|
if title.ReleaseSeason != nil {
|
||||||
|
release_season = oapi.ReleaseSeason(*title.ReleaseSeason)
|
||||||
|
}
|
||||||
|
oapi_title.ReleaseSeason = &release_season
|
||||||
|
|
||||||
|
oapi_status, err := TitleStatus2oapi(&title.TitleStatus)
|
||||||
|
if err != nil {
|
||||||
|
return oapi.Title{}, fmt.Errorf("TitleStatus2oapi: %v", err)
|
||||||
|
}
|
||||||
|
oapi_title.TitleStatus = oapi_status
|
||||||
|
|
||||||
|
return oapi_title, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64(s string) (int64, error) {
|
||||||
i, err := strconv.ParseInt(s, 10, 64)
|
i, err := strconv.ParseInt(s, 10, 64)
|
||||||
return int32(i), err
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TitleStatus2Sqlc(s *[]oapi.TitleStatus) ([]sqlc.TitleStatusT, error) {
|
||||||
|
var sqlc_status []sqlc.TitleStatusT
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for _, t := range *s {
|
||||||
|
switch t {
|
||||||
|
case oapi.TitleStatusFinished:
|
||||||
|
sqlc_status = append(sqlc_status, sqlc.TitleStatusTFinished)
|
||||||
|
case oapi.TitleStatusOngoing:
|
||||||
|
sqlc_status = append(sqlc_status, sqlc.TitleStatusTOngoing)
|
||||||
|
case oapi.TitleStatusPlanned:
|
||||||
|
sqlc_status = append(sqlc_status, sqlc.TitleStatusTPlanned)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected tittle status: %s", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sqlc_status, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
modules/backend/handlers/cursor.go
Normal file
156
modules/backend/handlers/cursor.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCursorInto parses an opaque base64 cursor and injects values into target struct.
|
||||||
|
//
|
||||||
|
// Supported sort types:
|
||||||
|
// - "id" → sets CursorID (must be *int64)
|
||||||
|
// - "year" → sets CursorID (*int64) + CursorYear (*int32)
|
||||||
|
// - "rating" → sets CursorID (*int64) + CursorRating (*float64)
|
||||||
|
//
|
||||||
|
// Target struct may have any subset of these fields (e.g. only CursorID).
|
||||||
|
// Unknown fields are ignored. Missing fields → values are dropped (safe).
|
||||||
|
//
|
||||||
|
// Returns error if cursor is invalid or inconsistent with sort_by.
|
||||||
|
func ParseCursorInto(sortBy, cursorStr string, target any) error {
|
||||||
|
if cursorStr == "" {
|
||||||
|
return nil // no cursor → nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Decode cursor
|
||||||
|
payload, err := decodeCursor(cursorStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Extract ID (required for all types)
|
||||||
|
id, err := extractInt64(payload, "id")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cursor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get reflect value of target (must be ptr to struct)
|
||||||
|
v := reflect.ValueOf(target)
|
||||||
|
if v.Kind() != reflect.Pointer || v.IsNil() {
|
||||||
|
return fmt.Errorf("target must be non-nil pointer to struct")
|
||||||
|
}
|
||||||
|
v = v.Elem()
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return fmt.Errorf("target must be pointer to struct")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Helper: set field if exists and compatible
|
||||||
|
setField := func(fieldName string, value any) {
|
||||||
|
f := v.FieldByName(fieldName)
|
||||||
|
if !f.IsValid() || !f.CanSet() {
|
||||||
|
return // field not found or unexported
|
||||||
|
}
|
||||||
|
ft := f.Type()
|
||||||
|
vv := reflect.ValueOf(value)
|
||||||
|
|
||||||
|
// Case: field is *T, value is T → wrap in pointer
|
||||||
|
if ft.Kind() == reflect.Pointer {
|
||||||
|
elemType := ft.Elem()
|
||||||
|
if vv.Type().AssignableTo(elemType) {
|
||||||
|
ptr := reflect.New(elemType)
|
||||||
|
ptr.Elem().Set(vv)
|
||||||
|
f.Set(ptr)
|
||||||
|
}
|
||||||
|
// nil → leave as zero (nil pointer)
|
||||||
|
} else if vv.Type().AssignableTo(ft) {
|
||||||
|
f.Set(vv)
|
||||||
|
}
|
||||||
|
// else: type mismatch → silently skip (safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Dispatch by sort type
|
||||||
|
switch sortBy {
|
||||||
|
case "id":
|
||||||
|
setField("CursorID", id)
|
||||||
|
|
||||||
|
case "year":
|
||||||
|
setField("CursorID", id)
|
||||||
|
param, err := extractString(payload, "param")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cursor year: %w", err)
|
||||||
|
}
|
||||||
|
year, err := strconv.Atoi(param)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cursor year: param must be integer, got %q", param)
|
||||||
|
}
|
||||||
|
setField("CursorYear", int32(year)) // or int, depending on your schema
|
||||||
|
|
||||||
|
case "rating":
|
||||||
|
setField("CursorID", id)
|
||||||
|
param, err := extractString(payload, "param")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cursor rating: %w", err)
|
||||||
|
}
|
||||||
|
rating, err := strconv.ParseFloat(param, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cursor rating: param must be float, got %q", param)
|
||||||
|
}
|
||||||
|
setField("CursorRating", rating)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported sort_by: %q", sortBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
func decodeCursor(cursorStr string) (map[string]any, error) {
|
||||||
|
data, err := base64.RawURLEncoding.DecodeString(cursorStr)
|
||||||
|
if err != nil {
|
||||||
|
data, err = base64.StdEncoding.DecodeString(cursorStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid base64 cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid cursor JSON: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractInt64(m map[string]any, key string) (int64, error) {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("missing %q", key)
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
if x == float64(int64(x)) {
|
||||||
|
return int64(x), nil
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
i, err := strconv.ParseInt(x, 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
case int64, int, int32:
|
||||||
|
return reflect.ValueOf(x).Int(), nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%q must be integer", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractString(m map[string]any, key string) (string, error) {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("missing %q", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%q must be string", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,10 @@ 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"
|
||||||
|
|
@ -19,18 +22,18 @@ func Word2Sqlc(s *string) *string {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func TitleStatus2Sqlc(s *oapi.TitleStatus) (*sqlc.TitleStatusT, error) {
|
func TitleStatus2oapi(s *sqlc.TitleStatusT) (*oapi.TitleStatus, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var t sqlc.TitleStatusT
|
var t oapi.TitleStatus
|
||||||
switch *s {
|
switch *s {
|
||||||
case oapi.TitleStatusFinished:
|
case sqlc.TitleStatusTFinished:
|
||||||
t = sqlc.TitleStatusTFinished
|
t = oapi.TitleStatusFinished
|
||||||
case oapi.TitleStatusOngoing:
|
case sqlc.TitleStatusTOngoing:
|
||||||
t = sqlc.TitleStatusTOngoing
|
t = oapi.TitleStatusOngoing
|
||||||
case oapi.TitleStatusPlanned:
|
case sqlc.TitleStatusTPlanned:
|
||||||
t = sqlc.TitleStatusTPlanned
|
t = oapi.TitleStatusPlanned
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unexpected tittle status: %s", *s)
|
return nil, fmt.Errorf("unexpected tittle status: %s", *s)
|
||||||
}
|
}
|
||||||
|
|
@ -80,170 +83,163 @@ func (s Server) GetTagsByTitleId(ctx context.Context, id int64) (oapi.Tags, erro
|
||||||
return oapi_tag_names, nil
|
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 = &storageTypeStr
|
// oapi_image.StorageType = string(storageTypeStr)
|
||||||
|
|
||||||
return &oapi_image, nil
|
// return &oapi_image, nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) {
|
// func (s Server) GetStudio(ctx context.Context, id int64) (*oapi.Studio, error) {
|
||||||
|
|
||||||
var oapi_studio oapi.Studio
|
// var oapi_studio oapi.Studio
|
||||||
|
|
||||||
sqlc_studio, err := s.db.GetStudioByID(ctx, id)
|
// 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) mapTitle(ctx context.Context, title sqlc.Title) (oapi.Title, error) {
|
func (s Server) GetTitle(ctx context.Context, request oapi.GetTitleRequestObject) (oapi.GetTitleResponseObject, 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.GetTitlesTitleId204Response{}, nil
|
return oapi.GetTitle204Response{}, nil
|
||||||
}
|
}
|
||||||
log.Errorf("%v", err)
|
log.Errorf("%v", err)
|
||||||
return oapi.GetTitlesTitleId500Response{}, nil
|
return oapi.GetTitle500Response{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
oapi_title, err = s.mapTitle(ctx, sqlc_title)
|
oapi_title, err = s.mapTitle(sqlc_title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("%v", err)
|
log.Errorf("%v", err)
|
||||||
return oapi.GetTitlesTitleId500Response{}, nil
|
return oapi.GetTitle500Response{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return oapi.GetTitlesTitleId200JSONResponse(oapi_title), nil
|
return oapi.GetTitle200JSONResponse(oapi_title), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) {
|
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)
|
||||||
cursor := oapi.CursorObj{
|
mqreq := rmq.RabbitRequest{
|
||||||
Id: 1,
|
Timestamp: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
word := Word2Sqlc(request.Params.Word)
|
word := Word2Sqlc(request.Params.Word)
|
||||||
status, err := TitleStatus2Sqlc(request.Params.Status)
|
if word != nil {
|
||||||
if err != nil {
|
mqreq.Name = *word
|
||||||
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
|
||||||
}
|
}
|
||||||
// param = nil means it will not be used
|
if season != nil {
|
||||||
titles, err := s.db.SearchTitles(ctx, sqlc.SearchTitlesParams{
|
mqreq.Season = *request.Params.ReleaseSeason
|
||||||
|
}
|
||||||
|
|
||||||
|
title_statuses, err := TitleStatus2Sqlc(request.Params.Status)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetTitles400Response{}, err
|
||||||
|
}
|
||||||
|
if title_statuses != nil {
|
||||||
|
mqreq.Statuses = *request.Params.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Params.ExtSearch != nil && *request.Params.ExtSearch {
|
||||||
|
|
||||||
|
// Структура для ответа (должна совпадать с тем, что шлёт микросервис)
|
||||||
|
var reply struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Preview string `json:"preview_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Делаем RPC-вызов — и ЖДЁМ ответа
|
||||||
|
err := s.RPCclient.Call(
|
||||||
|
ctx,
|
||||||
|
"svc.media.process.requests", // ← очередь микросервиса
|
||||||
|
mqreq,
|
||||||
|
&reply,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("RabitMQ: %v", err)
|
||||||
|
// return oapi.GetTitles500Response{}, err
|
||||||
|
}
|
||||||
|
// // Возвращаем результат
|
||||||
|
// return oapi.ProcessMedia200JSONResponse{
|
||||||
|
// Status: reply.Status,
|
||||||
|
// Result: reply.Result,
|
||||||
|
// Preview: reply.Preview,
|
||||||
|
// }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := sqlc.SearchTitlesParams{
|
||||||
Word: word,
|
Word: word,
|
||||||
Status: status,
|
TitleStatuses: title_statuses,
|
||||||
Rating: request.Params.Rating,
|
Rating: request.Params.Rating,
|
||||||
ReleaseYear: request.Params.ReleaseYear,
|
ReleaseYear: request.Params.ReleaseYear,
|
||||||
ReleaseSeason: season,
|
ReleaseSeason: season,
|
||||||
Forward: true,
|
Forward: true, // default
|
||||||
SortBy: "id",
|
SortBy: "id", // default
|
||||||
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), ¶ms)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetTitles400Response{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// param = nil means it will not be used
|
||||||
|
titles, err := s.db.SearchTitles(ctx, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("%v", err)
|
log.Errorf("%v", err)
|
||||||
return oapi.GetTitles500Response{}, nil
|
return oapi.GetTitles500Response{}, nil
|
||||||
|
|
@ -252,15 +248,58 @@ 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 {
|
||||||
|
|
||||||
t, err := s.mapTitle(ctx, title)
|
_title := sqlc.GetTitleByIDRow{
|
||||||
|
ID: title.ID,
|
||||||
|
// StudioID: title.StudioID,
|
||||||
|
PosterID: title.PosterID,
|
||||||
|
TitleStatus: title.TitleStatus,
|
||||||
|
Rating: title.Rating,
|
||||||
|
RatingCount: title.RatingCount,
|
||||||
|
ReleaseYear: title.ReleaseYear,
|
||||||
|
ReleaseSeason: title.ReleaseSeason,
|
||||||
|
Season: title.Season,
|
||||||
|
EpisodesAired: title.EpisodesAired,
|
||||||
|
EpisodesAll: title.EpisodesAll,
|
||||||
|
// EpisodesLen: title.EpisodesLen,
|
||||||
|
TitleStorageType: title.TitleStorageType,
|
||||||
|
TitleImagePath: title.TitleImagePath,
|
||||||
|
TitleNames: title.TitleNames,
|
||||||
|
TagNames: title.TagNames,
|
||||||
|
StudioName: title.StudioName,
|
||||||
|
// StudioIllustID: title.StudioIllustID,
|
||||||
|
// StudioDesc: title.StudioDesc,
|
||||||
|
// StudioStorageType: title.StudioStorageType,
|
||||||
|
// StudioImagePath: title.StudioImagePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// if title.TitleStorageType != nil {
|
||||||
|
// s := *title.TitleStorageType
|
||||||
|
// _title.TitleStorageType = string(s)
|
||||||
|
// }
|
||||||
|
|
||||||
|
t, err := s.mapTitle(_title)
|
||||||
if err != nil {
|
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: cursor, Data: opai_titles}, nil
|
return oapi.GetTitles200JSONResponse{Cursor: new_cursor, Data: opai_titles}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,82 +2,486 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// type Server struct {
|
const (
|
||||||
// db *sqlc.Queries
|
pgErrDuplicateKey = "23505"
|
||||||
// }
|
)
|
||||||
|
|
||||||
// func NewServer(db *sqlc.Queries) Server {
|
func mapUser(u sqlc.GetUserByIDRow) (oapi.User, error) {
|
||||||
// return Server{db: db}
|
i := oapi.Image{
|
||||||
// }
|
Id: u.AvatarID,
|
||||||
|
ImagePath: u.ImagePath,
|
||||||
// func parseInt64(s string) (int32, error) {
|
}
|
||||||
// i, err := strconv.ParseInt(s, 10, 64)
|
s, err := sql2StorageType(u.StorageType)
|
||||||
// return int32(i), err
|
if err != nil {
|
||||||
// }
|
return oapi.User{}, fmt.Errorf("mapUser, storage type: %v", err)
|
||||||
|
}
|
||||||
func mapUser(u sqlc.GetUserByIDRow) oapi.User {
|
i.StorageType = s
|
||||||
return oapi.User{
|
return oapi.User{
|
||||||
AvatarId: u.AvatarID,
|
Image: &i,
|
||||||
CreationDate: &u.CreationDate,
|
CreationDate: &u.CreationDate,
|
||||||
DispName: u.DispName,
|
DispName: u.DispName,
|
||||||
Id: &u.ID,
|
Id: &u.ID,
|
||||||
Mail: (*types.Email)(u.Mail),
|
Mail: StringToEmail(u.Mail),
|
||||||
Nickname: u.Nickname,
|
Nickname: u.Nickname,
|
||||||
UserDesc: u.UserDesc,
|
UserDesc: u.UserDesc,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) {
|
func (s Server) GetUsersId(ctx context.Context, req oapi.GetUsersIdRequestObject) (oapi.GetUsersIdResponseObject, error) {
|
||||||
userID, err := parseInt64(req.UserId)
|
userID, err := parseInt64(req.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oapi.GetUsersUserId404Response{}, nil
|
return oapi.GetUsersId404Response{}, nil
|
||||||
}
|
}
|
||||||
user, err := s.db.GetUserByID(context.TODO(), int64(userID))
|
_user, err := s.db.GetUserByID(context.TODO(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
return oapi.GetUsersUserId404Response{}, nil
|
return oapi.GetUsersId404Response{}, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil
|
user, err := mapUser(_user)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUsersId500Response{}, err
|
||||||
|
}
|
||||||
|
return oapi.GetUsersId200JSONResponse(user), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Server) GetUsersUserIdTitles(ctx context.Context, request oapi.GetUsersUserIdTitlesRequestObject) (oapi.GetUsersUserIdTitlesResponseObject, error) {
|
func sqlDate2oapi(p_date pgtype.Timestamptz) *time.Time {
|
||||||
|
if p_date.Valid {
|
||||||
|
t := p_date.Time
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var rate int32 = 9
|
// func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) (*SqlcUserStatus, error) {
|
||||||
var review_id int64 = 3
|
// var sqlc_status SqlcUserStatus
|
||||||
time := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
// if s == nil {
|
||||||
|
// return &sqlc_status, nil
|
||||||
|
// }
|
||||||
|
// for _, t := range *s {
|
||||||
|
// switch t {
|
||||||
|
// case oapi.UserTitleStatusFinished:
|
||||||
|
// sqlc_status.finished = "finished"
|
||||||
|
// case oapi.UserTitleStatusDropped:
|
||||||
|
// sqlc_status.dropped = "dropped"
|
||||||
|
// case oapi.UserTitleStatusPlanned:
|
||||||
|
// sqlc_status.planned = "planned"
|
||||||
|
// case oapi.UserTitleStatusInProgress:
|
||||||
|
// sqlc_status.in_progress = "in-progress"
|
||||||
|
// default:
|
||||||
|
// return nil, fmt.Errorf("unexpected tittle status: %s", t)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return &sqlc_status, nil
|
||||||
|
// }
|
||||||
|
|
||||||
var userTitles = []oapi.UserTitle{
|
func sql2usertitlestatus(s sqlc.UsertitleStatusT) (oapi.UserTitleStatus, error) {
|
||||||
{
|
var status oapi.UserTitleStatus
|
||||||
UserId: 101,
|
|
||||||
TitleId: 2001,
|
switch s {
|
||||||
Status: oapi.UserTitleStatusFinished,
|
case sqlc.UsertitleStatusTFinished:
|
||||||
Rate: &rate,
|
status = oapi.UserTitleStatusFinished
|
||||||
Ctime: &time,
|
case sqlc.UsertitleStatusTDropped:
|
||||||
},
|
status = oapi.UserTitleStatusDropped
|
||||||
{
|
case sqlc.UsertitleStatusTPlanned:
|
||||||
UserId: 102,
|
status = oapi.UserTitleStatusPlanned
|
||||||
TitleId: 2002,
|
case sqlc.UsertitleStatusTInProgress:
|
||||||
Status: oapi.UserTitleStatusInProgress,
|
status = oapi.UserTitleStatusInProgress
|
||||||
ReviewId: &review_id,
|
default:
|
||||||
Ctime: &time,
|
return status, fmt.Errorf("unexpected tittle status: %s", s)
|
||||||
},
|
|
||||||
{
|
|
||||||
UserId: 103,
|
|
||||||
TitleId: 2003,
|
|
||||||
Status: oapi.UserTitleStatusDropped,
|
|
||||||
Ctime: &time,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return oapi.GetUsersUserIdTitles200JSONResponse(userTitles), nil
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserTitleStatus2Sqlc(s *[]oapi.UserTitleStatus) ([]sqlc.UsertitleStatusT, error) {
|
||||||
|
var sqlc_status []sqlc.UsertitleStatusT
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for _, t := range *s {
|
||||||
|
switch t {
|
||||||
|
case oapi.UserTitleStatusFinished:
|
||||||
|
sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTFinished)
|
||||||
|
case oapi.UserTitleStatusInProgress:
|
||||||
|
sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTInProgress)
|
||||||
|
case oapi.UserTitleStatusDropped:
|
||||||
|
sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTDropped)
|
||||||
|
case oapi.UserTitleStatusPlanned:
|
||||||
|
sqlc_status = append(sqlc_status, sqlc.UsertitleStatusTPlanned)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected tittle status: %s", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sqlc_status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserTitleStatus2Sqlc1(s *oapi.UserTitleStatus) (*sqlc.UsertitleStatusT, error) {
|
||||||
|
var sqlc_status sqlc.UsertitleStatusT = sqlc.UsertitleStatusTFinished
|
||||||
|
if s == nil {
|
||||||
|
return &sqlc_status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch *s {
|
||||||
|
case oapi.UserTitleStatusFinished:
|
||||||
|
sqlc_status = sqlc.UsertitleStatusTFinished
|
||||||
|
case oapi.UserTitleStatusInProgress:
|
||||||
|
sqlc_status = sqlc.UsertitleStatusTInProgress
|
||||||
|
case oapi.UserTitleStatusDropped:
|
||||||
|
sqlc_status = sqlc.UsertitleStatusTDropped
|
||||||
|
case oapi.UserTitleStatusPlanned:
|
||||||
|
sqlc_status = sqlc.UsertitleStatusTPlanned
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected tittle status: %s", *s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sqlc_status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Server) mapUsertitle(ctx context.Context, t sqlc.SearchUserTitlesRow) (oapi.UserTitle, error) {
|
||||||
|
|
||||||
|
oapi_usertitle := oapi.UserTitle{
|
||||||
|
Ctime: &t.UserCtime,
|
||||||
|
Rate: t.UserRate,
|
||||||
|
ReviewId: t.ReviewID,
|
||||||
|
// Status: ,
|
||||||
|
// Title: ,
|
||||||
|
UserId: t.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := sql2usertitlestatus(t.UsertitleStatus)
|
||||||
|
if err != nil {
|
||||||
|
return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err)
|
||||||
|
}
|
||||||
|
oapi_usertitle.Status = status
|
||||||
|
|
||||||
|
_title := sqlc.GetTitleByIDRow{
|
||||||
|
ID: t.ID,
|
||||||
|
// StudioID: title.StudioID,
|
||||||
|
PosterID: t.PosterID,
|
||||||
|
TitleStatus: t.TitleStatus,
|
||||||
|
Rating: t.Rating,
|
||||||
|
RatingCount: t.RatingCount,
|
||||||
|
ReleaseYear: t.ReleaseYear,
|
||||||
|
ReleaseSeason: t.ReleaseSeason,
|
||||||
|
Season: t.Season,
|
||||||
|
EpisodesAired: t.EpisodesAired,
|
||||||
|
EpisodesAll: t.EpisodesAll,
|
||||||
|
// EpisodesLen: title.EpisodesLen,
|
||||||
|
TitleStorageType: t.TitleStorageType,
|
||||||
|
TitleImagePath: t.TitleImagePath,
|
||||||
|
StudioName: t.StudioName,
|
||||||
|
TitleNames: t.TitleNames,
|
||||||
|
TagNames: t.TagNames,
|
||||||
|
// StudioIllustID: title.StudioIllustID,
|
||||||
|
// StudioDesc: title.StudioDesc,
|
||||||
|
// StudioStorageType: title.StudioStorageType,
|
||||||
|
// StudioImagePath: title.StudioImagePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
oapi_title, err := s.mapTitle(_title)
|
||||||
|
if err != nil {
|
||||||
|
return oapi_usertitle, fmt.Errorf("mapUsertitle: %v", err)
|
||||||
|
}
|
||||||
|
oapi_usertitle.Title = &oapi_title
|
||||||
|
|
||||||
|
return oapi_usertitle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Server) GetUserTitles(ctx context.Context, request oapi.GetUserTitlesRequestObject) (oapi.GetUserTitlesResponseObject, error) {
|
||||||
|
|
||||||
|
oapi_usertitles := make([]oapi.UserTitle, 0)
|
||||||
|
|
||||||
|
word := Word2Sqlc(request.Params.Word)
|
||||||
|
|
||||||
|
season, err := ReleaseSeason2sqlc(request.Params.ReleaseSeason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitles400Response{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// var statuses_sort []string
|
||||||
|
// if request.Params.Status != nil {
|
||||||
|
// for _, s := range *request.Params.Status {
|
||||||
|
// ss := string(s) // s type is alias for string
|
||||||
|
// statuses_sort = append(statuses_sort, ss)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
watch_status, err := UserTitleStatus2Sqlc(request.Params.WatchStatus)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitles400Response{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
title_statuses, err := TitleStatus2Sqlc(request.Params.Status)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitles400Response{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := parseInt64(request.UserId)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("get user titles: %v", err)
|
||||||
|
return oapi.GetUserTitles404Response{}, err
|
||||||
|
}
|
||||||
|
params := sqlc.SearchUserTitlesParams{
|
||||||
|
UserID: userID,
|
||||||
|
Word: word,
|
||||||
|
TitleStatuses: title_statuses,
|
||||||
|
UsertitleStatuses: watch_status,
|
||||||
|
Rating: request.Params.Rating,
|
||||||
|
Rate: request.Params.MyRate,
|
||||||
|
ReleaseYear: request.Params.ReleaseYear,
|
||||||
|
ReleaseSeason: season,
|
||||||
|
Forward: true, // default
|
||||||
|
SortBy: "id", // default
|
||||||
|
Limit: request.Params.Limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Params.SortForward != nil {
|
||||||
|
params.Forward = *request.Params.SortForward
|
||||||
|
}
|
||||||
|
if request.Params.Sort != nil {
|
||||||
|
params.SortBy = string(*request.Params.Sort)
|
||||||
|
if request.Params.Cursor != nil {
|
||||||
|
// here we set CursorYear CursorID CursorRating fields
|
||||||
|
err := ParseCursorInto(string(*request.Params.Sort), string(*request.Params.Cursor), ¶ms)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitles400Response{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// param = nil means it will not be used
|
||||||
|
titles, err := s.db.SearchUserTitles(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitles500Response{}, nil
|
||||||
|
}
|
||||||
|
if len(titles) == 0 {
|
||||||
|
return oapi.GetUserTitles204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var new_cursor oapi.CursorObj
|
||||||
|
|
||||||
|
for _, title := range titles {
|
||||||
|
|
||||||
|
t, err := s.mapUsertitle(ctx, title)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitles500Response{}, nil
|
||||||
|
}
|
||||||
|
oapi_usertitles = append(oapi_usertitles, t)
|
||||||
|
|
||||||
|
new_cursor.Id = t.Title.Id
|
||||||
|
if request.Params.Sort != nil {
|
||||||
|
switch string(*request.Params.Sort) {
|
||||||
|
case "year":
|
||||||
|
tmp := fmt.Sprint(*t.Title.ReleaseYear)
|
||||||
|
new_cursor.Param = &tmp
|
||||||
|
case "rating":
|
||||||
|
tmp := strconv.FormatFloat(*t.Title.Rating, 'f', -1, 64) // падает
|
||||||
|
new_cursor.Param = &tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return oapi.GetUserTitles200JSONResponse{Cursor: new_cursor, Data: oapi_usertitles}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmailToStringPtr(e *types.Email) *string {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := string(*e)
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func StringToEmail(e *string) *types.Email {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := types.Email(*e)
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser implements oapi.StrictServerInterface.
|
||||||
|
func (s Server) UpdateUser(ctx context.Context, request oapi.UpdateUserRequestObject) (oapi.UpdateUserResponseObject, error) {
|
||||||
|
|
||||||
|
params := sqlc.UpdateUserParams{
|
||||||
|
AvatarID: request.Body.AvatarId,
|
||||||
|
DispName: request.Body.DispName,
|
||||||
|
UserDesc: request.Body.UserDesc,
|
||||||
|
Mail: EmailToStringPtr(request.Body.Mail),
|
||||||
|
UserID: request.UserId,
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.db.UpdateUser(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.UpdateUser500Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oapi_user := oapi.User{ // maybe its possible to make one sqlc type and use one map func iinstead of this shit
|
||||||
|
// AvatarId: user.AvatarID,
|
||||||
|
CreationDate: &user.CreationDate,
|
||||||
|
DispName: user.DispName,
|
||||||
|
Id: &user.ID,
|
||||||
|
Mail: StringToEmail(user.Mail),
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
UserDesc: user.UserDesc,
|
||||||
|
}
|
||||||
|
|
||||||
|
return oapi.UpdateUser200JSONResponse(oapi_user), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Server) AddUserTitle(ctx context.Context, request oapi.AddUserTitleRequestObject) (oapi.AddUserTitleResponseObject, error) {
|
||||||
|
//TODO: add review if exists
|
||||||
|
status, err := UserTitleStatus2Sqlc1(&request.Body.Status)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.AddUserTitle400Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := sqlc.InsertUserTitleParams{
|
||||||
|
UserID: request.UserId,
|
||||||
|
TitleID: request.Body.TitleId,
|
||||||
|
Status: *status,
|
||||||
|
Rate: request.Body.Rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
user_title, err := s.db.InsertUserTitle(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
// fmt.Println(pgErr.Message) // => syntax error at end of input
|
||||||
|
// fmt.Println(pgErr.Code) // => 42601
|
||||||
|
if pgErr.Code == pgErrDuplicateKey { //duplicate key value
|
||||||
|
return oapi.AddUserTitle409Response{}, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.AddUserTitle500Response{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oapi_status, err := sql2usertitlestatus(user_title.Status)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.AddUserTitle500Response{}, nil
|
||||||
|
}
|
||||||
|
oapi_usertitle := oapi.UserTitleMini{
|
||||||
|
Ctime: &user_title.Ctime,
|
||||||
|
Rate: user_title.Rate,
|
||||||
|
ReviewId: user_title.ReviewID,
|
||||||
|
Status: oapi_status,
|
||||||
|
TitleId: user_title.TitleID,
|
||||||
|
UserId: user_title.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return oapi.AddUserTitle200JSONResponse(oapi_usertitle), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserTitle implements oapi.StrictServerInterface.
|
||||||
|
func (s Server) DeleteUserTitle(ctx context.Context, request oapi.DeleteUserTitleRequestObject) (oapi.DeleteUserTitleResponseObject, error) {
|
||||||
|
params := sqlc.DeleteUserTitleParams{
|
||||||
|
UserID: request.UserId,
|
||||||
|
TitleID: request.TitleId,
|
||||||
|
}
|
||||||
|
_, err := s.db.DeleteUserTitle(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return oapi.DeleteUserTitle404Response{}, nil
|
||||||
|
}
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.DeleteUserTitle500Response{}, nil
|
||||||
|
}
|
||||||
|
return oapi.DeleteUserTitle200Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserTitle implements oapi.StrictServerInterface.
|
||||||
|
func (s Server) UpdateUserTitle(ctx context.Context, request oapi.UpdateUserTitleRequestObject) (oapi.UpdateUserTitleResponseObject, error) {
|
||||||
|
|
||||||
|
status, err := UserTitleStatus2Sqlc1(request.Body.Status)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.UpdateUserTitle400Response{}, nil
|
||||||
|
}
|
||||||
|
params := sqlc.UpdateUserTitleParams{
|
||||||
|
Status: status,
|
||||||
|
Rate: request.Body.Rate,
|
||||||
|
UserID: request.UserId,
|
||||||
|
TitleID: request.TitleId,
|
||||||
|
}
|
||||||
|
|
||||||
|
user_title, err := s.db.UpdateUserTitle(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return oapi.UpdateUserTitle404Response{}, nil
|
||||||
|
}
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.UpdateUserTitle500Response{}, nil
|
||||||
|
}
|
||||||
|
oapi_status, err := sql2usertitlestatus(user_title.Status)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.UpdateUserTitle500Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oapi_usertitle := oapi.UserTitleMini{
|
||||||
|
Ctime: &user_title.Ctime,
|
||||||
|
Rate: user_title.Rate,
|
||||||
|
ReviewId: user_title.ReviewID,
|
||||||
|
Status: oapi_status,
|
||||||
|
TitleId: user_title.TitleID,
|
||||||
|
UserId: user_title.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return oapi.UpdateUserTitle200JSONResponse(oapi_usertitle), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Server) GetUserTitle(ctx context.Context, request oapi.GetUserTitleRequestObject) (oapi.GetUserTitleResponseObject, error) {
|
||||||
|
user_title, err := s.db.GetUserTitleByID(ctx, sqlc.GetUserTitleByIDParams{
|
||||||
|
TitleID: request.TitleId,
|
||||||
|
UserID: request.UserId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return oapi.GetUserTitle404Response{}, nil
|
||||||
|
} else {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitle500Response{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oapi_status, err := sql2usertitlestatus(user_title.Status)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("%v", err)
|
||||||
|
return oapi.GetUserTitle500Response{}, nil
|
||||||
|
}
|
||||||
|
oapi_usertitle := oapi.UserTitleMini{
|
||||||
|
Ctime: &user_title.Ctime,
|
||||||
|
Rate: user_title.Rate,
|
||||||
|
ReviewId: user_title.ReviewID,
|
||||||
|
Status: oapi_status,
|
||||||
|
TitleId: user_title.TitleID,
|
||||||
|
UserId: user_title.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return oapi.GetUserTitle200JSONResponse(oapi_usertitle), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
sqlc "nyanimedb/sql"
|
sqlc "nyanimedb/sql"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
@ -10,11 +11,14 @@ 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
|
||||||
|
|
@ -43,12 +47,27 @@ func main() {
|
||||||
|
|
||||||
queries := sqlc.New(pool)
|
queries := sqlc.New(pool)
|
||||||
|
|
||||||
server := handlers.NewServer(queries)
|
// === RabbitMQ setup ===
|
||||||
|
rmqURL := os.Getenv("RABBITMQ_URL")
|
||||||
|
if rmqURL == "" {
|
||||||
|
rmqURL = "amqp://guest:guest@rabbitmq:5672/"
|
||||||
|
}
|
||||||
|
|
||||||
|
rmqConn, err := amqp091.Dial(rmqURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||||
|
}
|
||||||
|
defer rmqConn.Close()
|
||||||
|
|
||||||
|
publisher := rmq.NewPublisher(rmqConn)
|
||||||
|
rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second)
|
||||||
|
|
||||||
|
server := handlers.NewServer(queries, publisher, rpcClient)
|
||||||
// r.LoadHTMLGlob("templates/*")
|
// r.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"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"},
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||||
ExposeHeaders: []string{"Content-Length"},
|
ExposeHeaders: []string{"Content-Length"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
|
|
@ -60,24 +79,12 @@ 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) {
|
// Запуск
|
||||||
// items := []Item{
|
log.Infof("Server starting on :8080")
|
||||||
// {ID: 1, Title: "First Item", Description: "This is the description of the first item."},
|
if err := r.Run(":8080"); err != nil && err != http.ErrServerClosed {
|
||||||
// {ID: 2, Title: "Second Item", Description: "This is the description of the second item."},
|
log.Fatalf("server failed: %v", err)
|
||||||
// {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 {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,19 @@ VALUES ($1, $2)
|
||||||
RETURNING id, storage_type, image_path;
|
RETURNING id, storage_type, image_path;
|
||||||
|
|
||||||
-- name: GetUserByID :one
|
-- name: GetUserByID :one
|
||||||
SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date
|
SELECT
|
||||||
FROM users
|
t.id as id,
|
||||||
WHERE id = $1;
|
t.avatar_id as avatar_id,
|
||||||
|
t.mail as mail,
|
||||||
|
t.nickname as nickname,
|
||||||
|
t.disp_name as disp_name,
|
||||||
|
t.user_desc as user_desc,
|
||||||
|
t.creation_date as creation_date,
|
||||||
|
i.storage_type as storage_type,
|
||||||
|
i.image_path as image_path
|
||||||
|
FROM users as t
|
||||||
|
LEFT JOIN images as i ON (t.avatar_id = i.id)
|
||||||
|
WHERE t.id = sqlc.arg('id')::bigint;
|
||||||
|
|
||||||
|
|
||||||
-- name: GetStudioByID :one
|
-- name: GetStudioByID :one
|
||||||
|
|
@ -47,218 +57,347 @@ VALUES (
|
||||||
sqlc.arg('tag_names')::jsonb)
|
sqlc.arg('tag_names')::jsonb)
|
||||||
RETURNING id, tag_names;
|
RETURNING id, tag_names;
|
||||||
|
|
||||||
-- -- name: ListUsers :many
|
-- name: UpdateUser :one
|
||||||
-- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date
|
UPDATE users
|
||||||
-- FROM users
|
SET
|
||||||
-- ORDER BY user_id
|
avatar_id = COALESCE(sqlc.narg('avatar_id'), avatar_id),
|
||||||
-- LIMIT $1 OFFSET $2;
|
disp_name = COALESCE(sqlc.narg('disp_name'), disp_name),
|
||||||
|
user_desc = COALESCE(sqlc.narg('user_desc'), user_desc),
|
||||||
-- -- name: CreateUser :one
|
mail = COALESCE(sqlc.narg('mail'), mail)
|
||||||
-- INSERT INTO users (avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date)
|
WHERE id = sqlc.arg('user_id')
|
||||||
-- VALUES ($1, $2, $3, $4, $5, $6, $7)
|
RETURNING id, avatar_id, nickname, disp_name, user_desc, creation_date, mail;
|
||||||
-- 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
|
||||||
SELECT *
|
-- sqlc.struct: TitlesFull
|
||||||
FROM titles
|
SELECT
|
||||||
WHERE id = sqlc.arg('title_id')::bigint;
|
t.*,
|
||||||
|
i.storage_type as title_storage_type,
|
||||||
|
i.image_path as title_image_path,
|
||||||
|
COALESCE(
|
||||||
|
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
|
||||||
|
'[]'::jsonb
|
||||||
|
)::jsonb as tag_names,
|
||||||
|
s.studio_name as studio_name,
|
||||||
|
s.illust_id as studio_illust_id,
|
||||||
|
s.studio_desc as studio_desc,
|
||||||
|
si.storage_type as studio_storage_type,
|
||||||
|
si.image_path as studio_image_path
|
||||||
|
|
||||||
|
FROM titles as t
|
||||||
|
LEFT JOIN images as i ON (t.poster_id = i.id)
|
||||||
|
LEFT JOIN title_tags as tt ON (t.id = tt.title_id)
|
||||||
|
LEFT JOIN tags as g ON (tt.tag_id = g.id)
|
||||||
|
LEFT JOIN studios as s ON (t.studio_id = s.id)
|
||||||
|
LEFT JOIN images as si ON (s.illust_id = si.id)
|
||||||
|
|
||||||
|
WHERE t.id = sqlc.arg('title_id')::bigint
|
||||||
|
GROUP BY
|
||||||
|
t.id, i.id, s.id, si.id;
|
||||||
|
|
||||||
-- name: SearchTitles :many
|
-- name: SearchTitles :many
|
||||||
SELECT
|
SELECT
|
||||||
*
|
t.id as id,
|
||||||
FROM titles
|
t.title_names as title_names,
|
||||||
WHERE
|
t.poster_id as poster_id,
|
||||||
CASE
|
t.title_status as title_status,
|
||||||
WHEN sqlc.narg('word')::text IS NOT NULL THEN
|
t.rating as rating,
|
||||||
(
|
t.rating_count as rating_count,
|
||||||
SELECT bool_and(
|
t.release_year as release_year,
|
||||||
EXISTS (
|
t.release_season as release_season,
|
||||||
SELECT 1
|
t.season as season,
|
||||||
FROM jsonb_each_text(title_names) AS t(key, val)
|
t.episodes_aired as episodes_aired,
|
||||||
WHERE val ILIKE pattern
|
t.episodes_all as episodes_all,
|
||||||
)
|
i.storage_type as title_storage_type,
|
||||||
)
|
i.image_path as title_image_path,
|
||||||
FROM unnest(
|
COALESCE(
|
||||||
ARRAY(
|
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
|
||||||
SELECT '%' || trim(w) || '%'
|
'[]'::jsonb
|
||||||
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
|
)::jsonb as tag_names,
|
||||||
WHERE trim(w) <> ''
|
s.studio_name as studio_name
|
||||||
)
|
|
||||||
) AS pattern
|
FROM titles as t
|
||||||
)
|
LEFT JOIN images as i ON (t.poster_id = i.id)
|
||||||
ELSE true
|
LEFT JOIN title_tags as tt ON (t.id = tt.title_id)
|
||||||
|
LEFT JOIN tags as g ON (tt.tag_id = g.id)
|
||||||
|
LEFT JOIN studios as s ON (t.studio_id = s.id)
|
||||||
|
|
||||||
|
WHERE
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.arg('forward')::boolean THEN
|
||||||
|
-- forward: greater than cursor (next page)
|
||||||
|
CASE sqlc.arg('sort_by')::text
|
||||||
|
WHEN 'year' THEN
|
||||||
|
(sqlc.narg('cursor_year')::int IS NULL) OR
|
||||||
|
(t.release_year > sqlc.narg('cursor_year')::int) OR
|
||||||
|
(t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'rating' THEN
|
||||||
|
(sqlc.narg('cursor_rating')::float IS NULL) OR
|
||||||
|
(t.rating > sqlc.narg('cursor_rating')::float) OR
|
||||||
|
(t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'id' THEN
|
||||||
|
(sqlc.narg('cursor_id')::bigint IS NULL) OR
|
||||||
|
(t.id > sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
ELSE true -- fallback
|
||||||
|
END
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
-- backward: less than cursor (prev page)
|
||||||
|
CASE sqlc.arg('sort_by')::text
|
||||||
|
WHEN 'year' THEN
|
||||||
|
(sqlc.narg('cursor_year')::int IS NULL) OR
|
||||||
|
(t.release_year < sqlc.narg('cursor_year')::int) OR
|
||||||
|
(t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'rating' THEN
|
||||||
|
(sqlc.narg('cursor_rating')::float IS NULL) OR
|
||||||
|
(t.rating < sqlc.narg('cursor_rating')::float) OR
|
||||||
|
(t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'id' THEN
|
||||||
|
(sqlc.narg('cursor_id')::bigint IS NULL) OR
|
||||||
|
(t.id < sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
END
|
END
|
||||||
|
|
||||||
AND (sqlc.narg('status')::title_status_t IS NULL OR title_status = sqlc.narg('status')::title_status_t)
|
AND (
|
||||||
AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float)
|
CASE
|
||||||
AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int)
|
WHEN sqlc.narg('word')::text IS NOT NULL THEN
|
||||||
AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t)
|
(
|
||||||
ORDER BY
|
SELECT bool_and(
|
||||||
-- Основной ключ: выбранное поле
|
EXISTS (
|
||||||
CASE
|
SELECT 1
|
||||||
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
|
FROM jsonb_each_text(t.title_names) AS t(key, val)
|
||||||
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
|
WHERE val ILIKE pattern
|
||||||
WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
|
)
|
||||||
-- WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
|
|
||||||
END ASC,
|
|
||||||
CASE
|
|
||||||
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id
|
|
||||||
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year
|
|
||||||
WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating
|
|
||||||
-- WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views
|
|
||||||
END DESC,
|
|
||||||
|
|
||||||
-- Вторичный ключ: id — только если НЕ сортируем по id
|
|
||||||
CASE
|
|
||||||
WHEN sqlc.arg(sort_by)::text != 'id' AND sqlc.arg(forward)::boolean THEN id
|
|
||||||
END ASC,
|
|
||||||
CASE
|
|
||||||
WHEN sqlc.arg(sort_by)::text != 'id' AND NOT sqlc.arg(forward)::boolean THEN id
|
|
||||||
END DESC
|
|
||||||
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
|
|
||||||
-- OFFSET sqlc.narg('offset')::int;
|
|
||||||
|
|
||||||
-- name: SearchUserTitles :many
|
|
||||||
SELECT
|
|
||||||
*
|
|
||||||
FROM usertitles as u
|
|
||||||
JOIN titles as t ON (u.title_id = t.id)
|
|
||||||
WHERE
|
|
||||||
CASE
|
|
||||||
WHEN sqlc.narg('word')::text IS NOT NULL THEN
|
|
||||||
(
|
|
||||||
SELECT bool_and(
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM jsonb_each_text(t.title_names) AS t(key, val)
|
|
||||||
WHERE val ILIKE pattern
|
|
||||||
)
|
)
|
||||||
|
FROM unnest(
|
||||||
|
ARRAY(
|
||||||
|
SELECT '%' || trim(w) || '%'
|
||||||
|
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
|
||||||
|
WHERE trim(w) <> ''
|
||||||
|
)
|
||||||
|
) AS pattern
|
||||||
)
|
)
|
||||||
FROM unnest(
|
ELSE true
|
||||||
ARRAY(
|
END
|
||||||
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('status')::title_status_t IS NULL OR t.title_status = sqlc.narg('status')::title_status_t)
|
AND (
|
||||||
|
sqlc.narg('title_statuses')::title_status_t[] IS NULL
|
||||||
|
OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL
|
||||||
|
OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0
|
||||||
|
OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[])
|
||||||
|
)
|
||||||
AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float)
|
AND (sqlc.narg('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, i.id, s.id
|
||||||
|
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN sqlc.arg('forward')::boolean THEN
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
|
||||||
|
END
|
||||||
|
END ASC,
|
||||||
|
CASE WHEN NOT sqlc.arg('forward')::boolean THEN
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
|
||||||
|
END
|
||||||
|
END DESC,
|
||||||
|
|
||||||
|
CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC
|
||||||
|
|
||||||
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
|
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
|
||||||
|
|
||||||
-- -- name: ListTitles :many
|
-- name: SearchUserTitles :many
|
||||||
-- SELECT title_id, title_names, studio_id, poster_id, signal_ids,
|
SELECT
|
||||||
-- title_status, rating, rating_count, release_year, release_season,
|
t.id as id,
|
||||||
-- season, episodes_aired, episodes_all, episodes_len
|
t.title_names as title_names,
|
||||||
-- FROM titles
|
t.poster_id as poster_id,
|
||||||
-- ORDER BY title_id
|
t.title_status as title_status,
|
||||||
-- LIMIT $1 OFFSET $2;
|
t.rating as rating,
|
||||||
|
t.rating_count as rating_count,
|
||||||
|
t.release_year as release_year,
|
||||||
|
t.release_season as release_season,
|
||||||
|
t.season as season,
|
||||||
|
t.episodes_aired as episodes_aired,
|
||||||
|
t.episodes_all as episodes_all,
|
||||||
|
u.user_id as user_id,
|
||||||
|
u.status as usertitle_status,
|
||||||
|
u.rate as user_rate,
|
||||||
|
u.review_id as review_id,
|
||||||
|
u.ctime as user_ctime,
|
||||||
|
i.storage_type as title_storage_type,
|
||||||
|
i.image_path as title_image_path,
|
||||||
|
COALESCE(
|
||||||
|
jsonb_agg(g.tag_names) FILTER (WHERE g.tag_names IS NOT NULL),
|
||||||
|
'[]'::jsonb
|
||||||
|
)::jsonb as tag_names,
|
||||||
|
s.studio_name as studio_name
|
||||||
|
|
||||||
-- -- name: UpdateTitle :one
|
FROM usertitles as u
|
||||||
-- UPDATE titles
|
JOIN titles as t ON (u.title_id = t.id)
|
||||||
-- SET
|
LEFT JOIN images as i ON (t.poster_id = i.id)
|
||||||
-- title_names = COALESCE(sqlc.narg('title_names'), title_names),
|
LEFT JOIN title_tags as tt ON (t.id = tt.title_id)
|
||||||
-- studio_id = COALESCE(sqlc.narg('studio_id'), studio_id),
|
LEFT JOIN tags as g ON (tt.tag_id = g.id)
|
||||||
-- poster_id = COALESCE(sqlc.narg('poster_id'), poster_id),
|
LEFT JOIN studios as s ON (t.studio_id = s.id)
|
||||||
-- signal_ids = COALESCE(sqlc.narg('signal_ids'), signal_ids),
|
|
||||||
-- title_status = COALESCE(sqlc.narg('title_status'), title_status),
|
WHERE
|
||||||
-- release_year = COALESCE(sqlc.narg('release_year'), release_year),
|
u.user_id = sqlc.arg('user_id')::bigint
|
||||||
-- release_season = COALESCE(sqlc.narg('release_season'), release_season),
|
AND
|
||||||
-- episodes_aired = COALESCE(sqlc.narg('episodes_aired'), episodes_aired),
|
CASE
|
||||||
-- episodes_all = COALESCE(sqlc.narg('episodes_all'), episodes_all),
|
WHEN sqlc.arg('forward')::boolean THEN
|
||||||
-- episodes_len = COALESCE(sqlc.narg('episodes_len'), episodes_len)
|
-- forward: greater than cursor (next page)
|
||||||
-- WHERE title_id = sqlc.arg('title_id')
|
CASE sqlc.arg('sort_by')::text
|
||||||
-- RETURNING *;
|
WHEN 'year' THEN
|
||||||
|
(sqlc.narg('cursor_year')::int IS NULL) OR
|
||||||
|
(t.release_year > sqlc.narg('cursor_year')::int) OR
|
||||||
|
(t.release_year = sqlc.narg('cursor_year')::int AND t.id > sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'rating' THEN
|
||||||
|
(sqlc.narg('cursor_rating')::float IS NULL) OR
|
||||||
|
(t.rating > sqlc.narg('cursor_rating')::float) OR
|
||||||
|
(t.rating = sqlc.narg('cursor_rating')::float AND t.id > sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'id' THEN
|
||||||
|
(sqlc.narg('cursor_id')::bigint IS NULL) OR
|
||||||
|
(t.id > sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
ELSE true -- fallback
|
||||||
|
END
|
||||||
|
|
||||||
|
ELSE
|
||||||
|
-- backward: less than cursor (prev page)
|
||||||
|
CASE sqlc.arg('sort_by')::text
|
||||||
|
WHEN 'year' THEN
|
||||||
|
(sqlc.narg('cursor_year')::int IS NULL) OR
|
||||||
|
(t.release_year < sqlc.narg('cursor_year')::int) OR
|
||||||
|
(t.release_year = sqlc.narg('cursor_year')::int AND t.id < sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'rating' THEN
|
||||||
|
(sqlc.narg('cursor_rating')::float IS NULL) OR
|
||||||
|
(t.rating < sqlc.narg('cursor_rating')::float) OR
|
||||||
|
(t.rating = sqlc.narg('cursor_rating')::float AND t.id < sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
WHEN 'id' THEN
|
||||||
|
(sqlc.narg('cursor_id')::bigint IS NULL) OR
|
||||||
|
(t.id < sqlc.narg('cursor_id')::bigint)
|
||||||
|
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
END
|
||||||
|
|
||||||
|
AND (
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.narg('word')::text IS NOT NULL THEN
|
||||||
|
(
|
||||||
|
SELECT bool_and(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_each_text(t.title_names) AS t(key, val)
|
||||||
|
WHERE val ILIKE pattern
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM unnest(
|
||||||
|
ARRAY(
|
||||||
|
SELECT '%' || trim(w) || '%'
|
||||||
|
FROM unnest(string_to_array(sqlc.narg('word')::text, ' ')) AS w
|
||||||
|
WHERE trim(w) <> ''
|
||||||
|
)
|
||||||
|
) AS pattern
|
||||||
|
)
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
)
|
||||||
|
|
||||||
|
AND (
|
||||||
|
sqlc.narg('title_statuses')::title_status_t[] IS NULL
|
||||||
|
OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) IS NULL
|
||||||
|
OR array_length(sqlc.narg('title_statuses')::title_status_t[], 1) = 0
|
||||||
|
OR t.title_status = ANY(sqlc.narg('title_statuses')::title_status_t[])
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
sqlc.narg('usertitle_statuses')::usertitle_status_t[] IS NULL
|
||||||
|
OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) IS NULL
|
||||||
|
OR array_length(sqlc.narg('usertitle_statuses')::usertitle_status_t[], 1) = 0
|
||||||
|
OR u.status = ANY(sqlc.narg('usertitle_statuses')::usertitle_status_t[])
|
||||||
|
)
|
||||||
|
AND (sqlc.narg('rate')::int IS NULL OR u.rate >= sqlc.narg('rate')::int)
|
||||||
|
AND (sqlc.narg('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float)
|
||||||
|
AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int)
|
||||||
|
AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t)
|
||||||
|
|
||||||
|
GROUP BY
|
||||||
|
t.id, u.user_id, u.status, u.rate, u.review_id, u.ctime, i.id, s.id
|
||||||
|
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN sqlc.arg('forward')::boolean THEN
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate
|
||||||
|
END
|
||||||
|
END ASC,
|
||||||
|
CASE WHEN NOT sqlc.arg('forward')::boolean THEN
|
||||||
|
CASE
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'id' THEN t.id
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'year' THEN t.release_year
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'rating' THEN t.rating
|
||||||
|
WHEN sqlc.arg('sort_by')::text = 'rate' THEN u.rate
|
||||||
|
END
|
||||||
|
END DESC,
|
||||||
|
|
||||||
|
CASE WHEN sqlc.arg('sort_by')::text <> 'id' THEN t.id END ASC
|
||||||
|
|
||||||
|
LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit
|
||||||
|
|
||||||
-- name: GetReviewByID :one
|
-- 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: CreateReview :one
|
-- name: InsertUserTitle :one
|
||||||
-- INSERT INTO reviews (user_id, title_id, image_ids, review_text, creation_date)
|
INSERT INTO usertitles (user_id, title_id, status, rate, review_id)
|
||||||
-- VALUES ($1, $2, $3, $4, $5)
|
VALUES (
|
||||||
-- RETURNING review_id, user_id, title_id, image_ids, review_text, creation_date;
|
sqlc.arg('user_id')::bigint,
|
||||||
|
sqlc.arg('title_id')::bigint,
|
||||||
|
sqlc.arg('status')::usertitle_status_t,
|
||||||
|
sqlc.narg('rate')::int,
|
||||||
|
sqlc.narg('review_id')::bigint
|
||||||
|
)
|
||||||
|
RETURNING user_id, title_id, status, rate, review_id, ctime;
|
||||||
|
|
||||||
-- -- name: UpdateReview :one
|
-- name: UpdateUserTitle :one
|
||||||
-- UPDATE reviews
|
-- Fails with sql.ErrNoRows if (user_id, title_id) not found
|
||||||
-- SET
|
UPDATE usertitles
|
||||||
-- image_ids = COALESCE(sqlc.narg('image_ids'), image_ids),
|
SET
|
||||||
-- review_text = COALESCE(sqlc.narg('review_text'), review_text)
|
status = COALESCE(sqlc.narg('status')::usertitle_status_t, status),
|
||||||
-- WHERE review_id = sqlc.arg('review_id')
|
rate = COALESCE(sqlc.narg('rate')::int, rate)
|
||||||
-- RETURNING *;
|
WHERE
|
||||||
|
user_id = sqlc.arg('user_id')
|
||||||
|
AND title_id = sqlc.arg('title_id')
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- -- name: DeleteReview :exec
|
-- name: DeleteUserTitle :one
|
||||||
-- DELETE FROM reviews
|
DELETE FROM usertitles
|
||||||
-- WHERE review_id = $1;
|
WHERE user_id = sqlc.arg('user_id')
|
||||||
|
AND title_id = sqlc.arg('title_id')
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ListReviewsByTitle :many
|
-- name: GetUserTitleByID :one
|
||||||
-- SELECT review_id, user_id, title_id, image_ids, review_text, creation_date
|
SELECT
|
||||||
-- FROM reviews
|
ut.*
|
||||||
-- WHERE title_id = $1
|
FROM usertitles as ut
|
||||||
-- ORDER BY creation_date DESC
|
WHERE ut.title_id = sqlc.arg('title_id')::bigint AND ut.user_id = sqlc.arg('user_id')::bigint;
|
||||||
-- 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;
|
|
||||||
261
modules/backend/rmq/rabbit.go
Normal file
261
modules/backend/rmq/rabbit.go
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
package rmq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
oapi "nyanimedb/api"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RabbitRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Statuses []oapi.TitleStatus `json:"statuses,omitempty"`
|
||||||
|
Rating float64 `json:"rating,omitempty"`
|
||||||
|
Year int32 `json:"year,omitempty"`
|
||||||
|
Season oapi.ReleaseSeason `json:"season,omitempty"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publisher — потокобезопасный публикатор с пулом каналов.
|
||||||
|
type Publisher struct {
|
||||||
|
conn *amqp.Connection
|
||||||
|
pool *sync.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPublisher создаёт новый Publisher.
|
||||||
|
// conn должен быть уже установленным и healthy.
|
||||||
|
// Рекомендуется передавать durable connection с reconnect-логикой.
|
||||||
|
func NewPublisher(conn *amqp.Connection) *Publisher {
|
||||||
|
return &Publisher{
|
||||||
|
conn: conn,
|
||||||
|
pool: &sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
ch, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
// Паника уместна: невозможность открыть канал — критическая ошибка инициализации
|
||||||
|
panic(fmt.Errorf("rmqpool: failed to create channel: %w", err))
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish публикует сообщение в указанную очередь.
|
||||||
|
// Очередь объявляется как durable (если не существует).
|
||||||
|
// Поддерживает context для отмены/таймаута.
|
||||||
|
func (p *Publisher) Publish(
|
||||||
|
ctx context.Context,
|
||||||
|
queueName string,
|
||||||
|
payload RabbitRequest,
|
||||||
|
opts ...PublishOption,
|
||||||
|
) error {
|
||||||
|
// Применяем опции
|
||||||
|
options := &publishOptions{
|
||||||
|
contentType: "application/json",
|
||||||
|
deliveryMode: amqp.Persistent,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализуем payload
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rmqpool: failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Берём канал из пула
|
||||||
|
ch := p.getChannel()
|
||||||
|
if ch == nil {
|
||||||
|
return fmt.Errorf("rmqpool: channel is nil (connection may be closed)")
|
||||||
|
}
|
||||||
|
defer p.returnChannel(ch)
|
||||||
|
|
||||||
|
// Объявляем очередь (idempotent)
|
||||||
|
q, err := ch.QueueDeclare(
|
||||||
|
queueName,
|
||||||
|
true, // durable
|
||||||
|
false, // auto-delete
|
||||||
|
false, // exclusive
|
||||||
|
false, // no-wait
|
||||||
|
nil, // args
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rmqpool: failed to declare queue %q: %w", queueName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подготавливаем сообщение
|
||||||
|
msg := amqp.Publishing{
|
||||||
|
DeliveryMode: options.deliveryMode,
|
||||||
|
ContentType: options.contentType,
|
||||||
|
Timestamp: options.timestamp,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Публикуем с учётом контекста
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
err := ch.Publish(
|
||||||
|
"", // exchange (default)
|
||||||
|
q.Name, // routing key
|
||||||
|
false, // mandatory
|
||||||
|
false, // immediate
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Publisher) getChannel() *amqp.Channel {
|
||||||
|
raw := p.pool.Get()
|
||||||
|
if raw == nil {
|
||||||
|
ch, _ := p.conn.Channel()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
ch := raw.(*amqp.Channel)
|
||||||
|
if ch.IsClosed() { // ← теперь есть!
|
||||||
|
ch.Close() // освободить ресурсы
|
||||||
|
ch, _ = p.conn.Channel()
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// returnChannel возвращает канал в пул, если он жив.
|
||||||
|
func (p *Publisher) returnChannel(ch *amqp.Channel) {
|
||||||
|
if ch != nil && !ch.IsClosed() {
|
||||||
|
p.pool.Put(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishOption позволяет кастомизировать публикацию.
|
||||||
|
type PublishOption func(*publishOptions)
|
||||||
|
|
||||||
|
type publishOptions struct {
|
||||||
|
contentType string
|
||||||
|
deliveryMode uint8
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContentType устанавливает Content-Type (по умолчанию "application/json").
|
||||||
|
func WithContentType(ct string) PublishOption {
|
||||||
|
return func(o *publishOptions) { o.contentType = ct }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTransient делает сообщение transient (не сохраняется на диск).
|
||||||
|
// По умолчанию — Persistent.
|
||||||
|
func WithTransient() PublishOption {
|
||||||
|
return func(o *publishOptions) { o.deliveryMode = amqp.Transient }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTimestamp устанавливает кастомную метку времени.
|
||||||
|
func WithTimestamp(ts time.Time) PublishOption {
|
||||||
|
return func(o *publishOptions) { o.timestamp = ts }
|
||||||
|
}
|
||||||
|
|
||||||
|
type RPCClient struct {
|
||||||
|
conn *amqp.Connection
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRPCClient(conn *amqp.Connection, timeout time.Duration) *RPCClient {
|
||||||
|
return &RPCClient{conn: conn, timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call отправляет запрос в очередь и ждёт ответа.
|
||||||
|
// replyPayload — указатель на структуру, в которую раскодировать ответ (например, &MediaResponse{}).
|
||||||
|
func (c *RPCClient) Call(
|
||||||
|
ctx context.Context,
|
||||||
|
requestQueue string,
|
||||||
|
request RabbitRequest,
|
||||||
|
replyPayload any,
|
||||||
|
) error {
|
||||||
|
// 1. Создаём временный канал (не из пула!)
|
||||||
|
ch, err := c.conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("channel: %w", err)
|
||||||
|
}
|
||||||
|
defer ch.Close()
|
||||||
|
|
||||||
|
// 2. Создаём временную очередь для ответов
|
||||||
|
q, err := ch.QueueDeclare(
|
||||||
|
"", // auto name
|
||||||
|
false, // not durable
|
||||||
|
true, // exclusive
|
||||||
|
true, // auto-delete
|
||||||
|
false,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reply queue: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Подписываемся на ответы
|
||||||
|
msgs, err := ch.Consume(
|
||||||
|
q.Name,
|
||||||
|
"",
|
||||||
|
true, // auto-ack
|
||||||
|
true, // exclusive
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("consume: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Готовим correlation ID
|
||||||
|
corrID := time.Now().UnixNano()
|
||||||
|
|
||||||
|
// 5. Сериализуем запрос
|
||||||
|
body, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Публикуем запрос
|
||||||
|
err = ch.Publish(
|
||||||
|
"",
|
||||||
|
requestQueue,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
amqp.Publishing{
|
||||||
|
ContentType: "application/json",
|
||||||
|
CorrelationId: fmt.Sprintf("%d", corrID),
|
||||||
|
ReplyTo: q.Name,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("publish: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Ждём ответ с таймаутом
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg := <-msgs:
|
||||||
|
if msg.CorrelationId == fmt.Sprintf("%d", corrID) {
|
||||||
|
return json.Unmarshal(msg.Body, replyPayload)
|
||||||
|
}
|
||||||
|
// игнорируем другие сообщения (маловероятно, но возможно)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err() // timeout or cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,15 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,38 @@ 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 = "nihonium";
|
const username = localStorage.getItem("username") || undefined;
|
||||||
|
const userId = localStorage.getItem("userId");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Header username={username} />
|
<Header username={username} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} /> {/* <-- маршрут для логина */}
|
{/* auth */}
|
||||||
<Route path="/signup" element={<LoginPage />} /> {/* <-- можно использовать тот же компонент для регистрации */}
|
<Route path="/login" 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;
|
||||||
|
|
@ -20,7 +20,7 @@ export type OpenAPIConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OpenAPI: OpenAPIConfig = {
|
export const OpenAPI: OpenAPIConfig = {
|
||||||
BASE: 'http://10.1.0.65:8081/api/v1',
|
BASE: '/api/v1',
|
||||||
VERSION: '1.0.0',
|
VERSION: '1.0.0',
|
||||||
WITH_CREDENTIALS: false,
|
WITH_CREDENTIALS: false,
|
||||||
CREDENTIALS: 'include',
|
CREDENTIALS: 'include',
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ 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';
|
||||||
|
|
@ -21,6 +22,7 @@ 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';
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
/* 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?: string;
|
storage_type?: StorageType;
|
||||||
image_path?: string;
|
image_path?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
8
modules/frontend/src/api/models/StorageType.ts
Normal file
8
modules/frontend/src/api/models/StorageType.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* Image storage type
|
||||||
|
*/
|
||||||
|
export type StorageType = 's3' | 'local';
|
||||||
|
|
@ -2,4 +2,30 @@
|
||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type Title = Record<string, any>;
|
import type { Image } from './Image';
|
||||||
|
import type { ReleaseSeason } from './ReleaseSeason';
|
||||||
|
import type { Studio } from './Studio';
|
||||||
|
import type { Tags } from './Tags';
|
||||||
|
import type { TitleStatus } from './TitleStatus';
|
||||||
|
export type Title = {
|
||||||
|
/**
|
||||||
|
* Unique title ID (primary key)
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Localized titles. Key = language (ISO 639-1), value = list of names
|
||||||
|
*/
|
||||||
|
title_names: Record<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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,13 @@
|
||||||
/* 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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,14 @@
|
||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export type UserTitle = Record<string, any>;
|
import type { Title } from './Title';
|
||||||
|
import type { UserTitleStatus } from './UserTitleStatus';
|
||||||
|
export type UserTitle = {
|
||||||
|
user_id: number;
|
||||||
|
title?: Title;
|
||||||
|
status: UserTitleStatus;
|
||||||
|
rate?: number;
|
||||||
|
review_id?: number;
|
||||||
|
ctime?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
14
modules/frontend/src/api/models/UserTitleMini.ts
Normal file
14
modules/frontend/src/api/models/UserTitleMini.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
import type { UserTitleStatus } from './UserTitleStatus';
|
||||||
|
export type UserTitleMini = {
|
||||||
|
user_id: number;
|
||||||
|
title_id: number;
|
||||||
|
status: UserTitleStatus;
|
||||||
|
rate?: number;
|
||||||
|
review_id?: number;
|
||||||
|
ctime?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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';
|
||||||
|
|
@ -20,7 +21,7 @@ export class DefaultService {
|
||||||
* @param sort
|
* @param sort
|
||||||
* @param sortForward
|
* @param sortForward
|
||||||
* @param word
|
* @param word
|
||||||
* @param status
|
* @param status List of title statuses to filter
|
||||||
* @param rating
|
* @param rating
|
||||||
* @param releaseYear
|
* @param releaseYear
|
||||||
* @param releaseSeason
|
* @param releaseSeason
|
||||||
|
|
@ -35,7 +36,7 @@ export class DefaultService {
|
||||||
sort?: TitleSort,
|
sort?: TitleSort,
|
||||||
sortForward: boolean = true,
|
sortForward: boolean = true,
|
||||||
word?: string,
|
word?: string,
|
||||||
status?: TitleStatus,
|
status?: Array<TitleStatus>,
|
||||||
rating?: number,
|
rating?: number,
|
||||||
releaseYear?: number,
|
releaseYear?: number,
|
||||||
releaseSeason?: ReleaseSeason,
|
releaseSeason?: ReleaseSeason,
|
||||||
|
|
@ -78,7 +79,7 @@ export class DefaultService {
|
||||||
* @returns Title Title description
|
* @returns Title Title description
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static getTitles1(
|
public static getTitle(
|
||||||
titleId: number,
|
titleId: number,
|
||||||
fields: string = 'all',
|
fields: string = 'all',
|
||||||
): CancelablePromise<Title> {
|
): CancelablePromise<Title> {
|
||||||
|
|
@ -105,7 +106,7 @@ export class DefaultService {
|
||||||
* @returns User User info
|
* @returns User User info
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static getUsers(
|
public static getUsersId(
|
||||||
userId: string,
|
userId: string,
|
||||||
fields: string = 'all',
|
fields: string = 'all',
|
||||||
): CancelablePromise<User> {
|
): CancelablePromise<User> {
|
||||||
|
|
@ -125,45 +126,112 @@ export class DefaultService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Partially update a user account
|
||||||
|
* Update selected user profile fields (excluding password).
|
||||||
|
* Password updates must be done via the dedicated auth-service (`/auth/`).
|
||||||
|
* Fields not provided in the request body remain unchanged.
|
||||||
|
*
|
||||||
|
* @param userId User ID (primary key)
|
||||||
|
* @param requestBody
|
||||||
|
* @returns User User updated successfully. Returns updated user representation (excluding sensitive fields).
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static updateUser(
|
||||||
|
userId: number,
|
||||||
|
requestBody: {
|
||||||
|
/**
|
||||||
|
* ID of the user avatar (references `images.id`); set to `null` to remove avatar
|
||||||
|
*/
|
||||||
|
avatar_id?: number | null;
|
||||||
|
/**
|
||||||
|
* User email (must be unique and valid)
|
||||||
|
*/
|
||||||
|
mail?: string;
|
||||||
|
/**
|
||||||
|
* Username (alphanumeric + `_` or `-`, 3–16 chars)
|
||||||
|
*/
|
||||||
|
nickname?: string;
|
||||||
|
/**
|
||||||
|
* Display name
|
||||||
|
*/
|
||||||
|
disp_name?: string;
|
||||||
|
/**
|
||||||
|
* User description / bio
|
||||||
|
*/
|
||||||
|
user_desc?: string;
|
||||||
|
},
|
||||||
|
): CancelablePromise<User> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/users/{user_id}',
|
||||||
|
path: {
|
||||||
|
'user_id': userId,
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
400: `Invalid input (e.g., validation failed, nickname/email conflict, malformed JSON)`,
|
||||||
|
401: `Unauthorized — missing or invalid authentication token`,
|
||||||
|
403: `Forbidden — user is not allowed to modify this resource (e.g., not own profile & no admin rights)`,
|
||||||
|
404: `User not found`,
|
||||||
|
409: `Conflict — e.g., requested \`nickname\` or \`mail\` already taken by another user`,
|
||||||
|
422: `Unprocessable Entity — semantic errors not caught by schema (e.g., invalid \`avatar_id\`)`,
|
||||||
|
500: `Unknown server error`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Get user titles
|
* Get user titles
|
||||||
* @param userId
|
* @param userId
|
||||||
* @param cursor
|
* @param cursor
|
||||||
|
* @param sort
|
||||||
|
* @param sortForward
|
||||||
* @param word
|
* @param word
|
||||||
* @param status
|
* @param status List of title statuses to filter
|
||||||
* @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 UserTitle List of user titles
|
* @returns any List of user titles
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static getUsersTitles(
|
public static getUserTitles(
|
||||||
userId: string,
|
userId: string,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
|
sort?: TitleSort,
|
||||||
|
sortForward: boolean = true,
|
||||||
word?: string,
|
word?: string,
|
||||||
status?: TitleStatus,
|
status?: Array<TitleStatus>,
|
||||||
watchStatus?: UserTitleStatus,
|
watchStatus?: Array<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<Array<UserTitle>> {
|
): CancelablePromise<{
|
||||||
|
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,
|
||||||
|
|
@ -171,8 +239,130 @@ 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`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export type OpenAPIConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OpenAPI: OpenAPIConfig = {
|
export const OpenAPI: OpenAPIConfig = {
|
||||||
BASE: 'http://127.0.0.1:8082',
|
BASE: '/auth',
|
||||||
VERSION: '1.0.0',
|
VERSION: '1.0.0',
|
||||||
WITH_CREDENTIALS: false,
|
WITH_CREDENTIALS: false,
|
||||||
CREDENTIALS: 'include',
|
CREDENTIALS: 'include',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 fixed top-0 left-0 z-50">
|
<header className="w-full bg-white shadow-md sticky top-0 left-0 z-50">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="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">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { DefaultService } from "../../api";
|
||||||
|
import type { UserTitleStatus } from "../../api";
|
||||||
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
// Статусы с иконками и подписью
|
||||||
|
const STATUS_BUTTONS: { status: UserTitleStatus; icon: React.ReactNode; label: string }[] = [
|
||||||
|
{ status: "planned", icon: <ClockIcon className="w-5 h-5" />, label: "Planned" },
|
||||||
|
{ status: "finished", icon: <CheckCircleIcon className="w-5 h-5" />, label: "Finished" },
|
||||||
|
{ status: "in-progress", icon: <PlayCircleIcon className="w-5 h-5" />, label: "In Progress" },
|
||||||
|
{ status: "dropped", icon: <XCircleIcon className="w-5 h-5" />, label: "Dropped" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TitleStatusControls({ titleId }: { titleId: number }) {
|
||||||
|
const [currentStatus, setCurrentStatus] = useState<UserTitleStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const userIdStr = localStorage.getItem("userId");
|
||||||
|
const userId = userIdStr ? Number(userIdStr) : null;
|
||||||
|
|
||||||
|
// --- Load initial status ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
DefaultService.getUserTitle(userId, titleId)
|
||||||
|
.then((res) => setCurrentStatus(res.status))
|
||||||
|
.catch(() => setCurrentStatus(null)); // 404 = user title does not exist
|
||||||
|
}, [titleId, userId]);
|
||||||
|
|
||||||
|
// --- Handle click ---
|
||||||
|
const handleStatusClick = async (status: UserTitleStatus) => {
|
||||||
|
if (!userId || loading) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Если кликнули на текущий статус — DELETE
|
||||||
|
if (currentStatus === status) {
|
||||||
|
await DefaultService.deleteUserTitle(userId, titleId);
|
||||||
|
setCurrentStatus(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Если другой статус — POST или PATCH
|
||||||
|
if (!currentStatus) {
|
||||||
|
// ещё нет записи — POST
|
||||||
|
const added = await DefaultService.addUserTitle(userId, {
|
||||||
|
title_id: titleId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
setCurrentStatus(added.status);
|
||||||
|
} else {
|
||||||
|
// уже есть запись — PATCH
|
||||||
|
const updated = await DefaultService.updateUserTitle(userId, titleId, { status });
|
||||||
|
setCurrentStatus(updated.status);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 flex-wrap justify-center mt-2">
|
||||||
|
{STATUS_BUTTONS.map(btn => (
|
||||||
|
<button
|
||||||
|
key={btn.status}
|
||||||
|
onClick={() => handleStatusClick(btn.status)}
|
||||||
|
disabled={loading}
|
||||||
|
className={`
|
||||||
|
px-3 py-1 rounded-md border flex items-center gap-1 transition
|
||||||
|
${currentStatus === btn.status
|
||||||
|
? "bg-blue-600 text-white border-blue-700"
|
||||||
|
: "bg-gray-200 text-black border-gray-300 hover:bg-gray-300"}
|
||||||
|
`}
|
||||||
|
title={btn.label}
|
||||||
|
>
|
||||||
|
{btn.icon}
|
||||||
|
<span>{btn.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { UserTitle } from "../../api";
|
||||||
|
|
||||||
|
export function UserTitleCardHorizontal({ title }: { title: UserTitle }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 12,
|
||||||
|
padding: 12,
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: 8
|
||||||
|
}}>
|
||||||
|
{title.title?.poster?.image_path && (
|
||||||
|
<img src={title.title?.poster.image_path} width={80} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3>{title.title?.title_names["en"]}</h3>
|
||||||
|
<p>{title.title?.release_year} · {title.title?.release_season} · Rating: {title.title?.rating}</p>
|
||||||
|
<p>Status: {title.title?.title_status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { UserTitle } from "../../api";
|
||||||
|
|
||||||
|
export function UserTitleCardSquare({ title }: { title: UserTitle }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 160,
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
textAlign: "center"
|
||||||
|
}}>
|
||||||
|
{title.title?.poster?.image_path && (
|
||||||
|
<img src={title.title?.poster.image_path} width={140} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h4>{title.title?.title_names["en"]}</h4>
|
||||||
|
<h5>{title.status}</h5>
|
||||||
|
<small>{title.title?.release_year} • {title.title?.rating}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,17 +18,19 @@ 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.success) {
|
if (res.user_id && res.user_name) {
|
||||||
// TODO: сохранить JWT в localStorage/cookie
|
// Сохраняем user_id и username в localStorage
|
||||||
console.log("Logged in user id:", res.user_id);
|
localStorage.setItem("userId", res.user_id);
|
||||||
navigate("/"); // редирект после успешного входа
|
localStorage.setItem("username", res.user_name);
|
||||||
|
|
||||||
|
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.success) {
|
if (res.user_id) {
|
||||||
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");
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,108 @@
|
||||||
// import React, { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
// import { useParams } from "react-router-dom";
|
import { useParams, Link } from "react-router-dom";
|
||||||
// import { DefaultService } from "../../api/services/DefaultService";
|
import { DefaultService } from "../../api/services/DefaultService";
|
||||||
// import type { User } from "../../api/models/User";
|
import type { Title } from "../../api";
|
||||||
// import styles from "./UserPage.module.css";
|
import { TitleStatusControls } from "../../components/TitleStatusControls/TitleStatusControls";
|
||||||
|
|
||||||
// const UserPage: React.FC = () => {
|
export default function TitlePage() {
|
||||||
// const { id } = useParams<{ id: string }>();
|
const params = useParams();
|
||||||
// const [user, setUser] = useState<User | null>(null);
|
const titleId = Number(params.id);
|
||||||
// const [loading, setLoading] = useState(true);
|
|
||||||
// const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
const [title, setTitle] = useState<Title | null>(null);
|
||||||
// if (!id) return;
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// const getTitleInfo = async () => {
|
// ---------------------------
|
||||||
// try {
|
// LOAD TITLE INFO
|
||||||
// const userInfo = await DefaultService.getTitle(id, "all");
|
// ---------------------------
|
||||||
// setUser(userInfo);
|
useEffect(() => {
|
||||||
// } catch (err) {
|
const fetchTitle = async () => {
|
||||||
// console.error(err);
|
setLoading(true);
|
||||||
// setError("Failed to fetch user info.");
|
try {
|
||||||
// } finally {
|
const data = await DefaultService.getTitle(titleId, "all");
|
||||||
// setLoading(false);
|
setTitle(data);
|
||||||
// }
|
setError(null);
|
||||||
// };
|
} catch (err: any) {
|
||||||
// getTitleInfo();
|
console.error(err);
|
||||||
// }, [id]);
|
setError(err?.message || "Failed to fetch title");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTitle();
|
||||||
|
}, [titleId]);
|
||||||
|
|
||||||
// if (loading) return <div className={styles.loader}>Loading...</div>;
|
const getTagsString = () =>
|
||||||
// if (error) return <div className={styles.error}>{error}</div>;
|
title?.tags?.map(tag => tag.en).filter(Boolean).join(", ");
|
||||||
// if (!user) return <div className={styles.error}>User not found.</div>;
|
|
||||||
|
|
||||||
// return (
|
if (loading) return <div className="mt-20 font-medium text-black">Loading title...</div>;
|
||||||
// <div className={styles.container}>
|
if (error) return <div className="mt-20 text-red-600 font-medium">{error}</div>;
|
||||||
// <div className={styles.card}>
|
if (!title) return null;
|
||||||
// <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>
|
|
||||||
|
|
||||||
// <div className={styles.info}>
|
return (
|
||||||
// <h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
|
<div className="w-full min-h-screen bg-gray-50 p-6 flex justify-center">
|
||||||
// <p className={styles.nickname}>@{user.nickname}</p>
|
<div className="flex flex-col md:flex-row bg-white shadow-lg rounded-xl max-w-4xl w-full p-6 gap-6">
|
||||||
// {user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
|
{/* Poster + status buttons */}
|
||||||
// <p className={styles.created}>
|
<div className="flex flex-col items-center">
|
||||||
// Joined: {new Date(user.creation_date).toLocaleDateString()}
|
<img
|
||||||
// </p>
|
src={title.poster?.image_path || "/default-poster.png"}
|
||||||
// </div>
|
alt={title.title_names?.en?.[0] || "Title poster"}
|
||||||
// </div>
|
className="w-48 h-72 object-cover rounded-lg mb-4"
|
||||||
// </div>
|
/>
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// export default UserPage;
|
{/* Status buttons */}
|
||||||
|
<TitleStatusControls titleId={titleId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title info */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">
|
||||||
|
{title.title_names?.en?.[0] || "Untitled"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{title.studio && (
|
||||||
|
<p className="text-gray-700 mb-1">
|
||||||
|
Studio:{" "}
|
||||||
|
{title.studio.id ? (
|
||||||
|
<Link
|
||||||
|
to={`/studios/${title.studio.id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{title.studio.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
title.studio.name
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{title.title_status && <p className="text-gray-700 mb-1">Status: {title.title_status}</p>}
|
||||||
|
|
||||||
|
{title.rating !== undefined && (
|
||||||
|
<p className="text-gray-700 mb-1">
|
||||||
|
Rating: {title.rating} ({title.rating_count} votes)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{title.release_year && (
|
||||||
|
<p className="text-gray-700 mb-1">
|
||||||
|
Released: {title.release_year} {title.release_season || ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{title.episodes_aired !== undefined && (
|
||||||
|
<p className="text-gray-700 mb-1">
|
||||||
|
Episodes: {title.episodes_aired}/{title.episodes_all}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{title.tags && title.tags.length > 0 && (
|
||||||
|
<p className="text-gray-700 mb-1">
|
||||||
|
Tags: {getTagsString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -135,11 +136,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) => (
|
||||||
layout === "square"
|
<Link to={`/titles/${title.id}`} key={title.id} className="block">
|
||||||
? <TitleCardSquare title={title} />
|
{layout === "square" ? <TitleCardSquare title={title} /> : <TitleCardHorizontal title={title} />}
|
||||||
: <TitleCardHorizontal title={title} />
|
</Link>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!cursor && nextPage.length == 0 && (
|
{!cursor && nextPage.length == 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
body,
|
|
||||||
html {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
background-color: #777;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
width: 100vw;
|
|
||||||
padding: 30px 40px;
|
|
||||||
background: #f7f7f7;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 25px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
||||||
border-bottom: 1px solid #e5e5e5;
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarWrapper {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
min-width: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarImg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatarPlaceholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ccc;
|
|
||||||
font-size: 42px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #555;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userInfo {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nickname {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 100vw;
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0%;
|
|
||||||
/* margin: 25px auto; */
|
|
||||||
/* padding: 0 20px; */
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.created {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader,
|
|
||||||
.error {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 40px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +1,184 @@
|
||||||
import React, { useEffect, useState } from "react";
|
// pages/UserPage/UserPage.tsx
|
||||||
import { useParams } from "react-router-dom"; // <-- import
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
import { DefaultService } from "../../api/services/DefaultService";
|
import { DefaultService } from "../../api/services/DefaultService";
|
||||||
import type { User } from "../../api/models/User";
|
import { SearchBar } from "../../components/SearchBar/SearchBar";
|
||||||
import styles from "./UserPage.module.css";
|
import { TitlesSortBox } from "../../components/TitlesSortBox/TitlesSortBox";
|
||||||
|
import { LayoutSwitch } from "../../components/LayoutSwitch/LayoutSwitch";
|
||||||
|
import { ListView } from "../../components/ListView/ListView";
|
||||||
|
import { UserTitleCardSquare } from "../../components/cards/UserTitleCardSquare";
|
||||||
|
import { UserTitleCardHorizontal } from "../../components/cards/UserTitleCardHorizontal";
|
||||||
|
import type { User, UserTitle, CursorObj, TitleSort } from "../../api";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
const UserPage: React.FC = () => {
|
const PAGE_SIZE = 10;
|
||||||
const { id } = useParams<{ id: string }>(); // <-- get user id from URL
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
type UserPageProps = {
|
||||||
if (!id) return;
|
userId?: string;
|
||||||
|
|
||||||
const getUserInfo = async () => {
|
|
||||||
try {
|
|
||||||
const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id
|
|
||||||
setUser(userInfo);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError("Failed to fetch user info.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
getUserInfo();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
if (loading) return <div className={styles.loader}>Loading...</div>;
|
|
||||||
if (error) return <div className={styles.error}>{error}</div>;
|
|
||||||
if (!user) return <div className={styles.error}>User not found.</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div className={styles.avatarWrapper}>
|
|
||||||
{user.avatar_id ? (
|
|
||||||
<img
|
|
||||||
src={`/images/${user.avatar_id}.png`}
|
|
||||||
alt="User Avatar"
|
|
||||||
className={styles.avatarImg}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.avatarPlaceholder}>
|
|
||||||
{user.disp_name?.[0] || "U"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.userInfo}>
|
|
||||||
<h1 className={styles.name}>{user.disp_name || user.nickname}</h1>
|
|
||||||
<p className={styles.nickname}>@{user.nickname}</p>
|
|
||||||
{/* <p className={styles.created}>
|
|
||||||
Joined: {new Date(user.creation_date).toLocaleDateString()}
|
|
||||||
</p> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.content}>
|
|
||||||
{user.user_desc && <p className={styles.desc}>{user.user_desc}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UserPage;
|
export default function UserPage({ userId }: UserPageProps) {
|
||||||
|
const params = useParams();
|
||||||
|
const id = userId || params?.id;
|
||||||
|
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loadingUser, setLoadingUser] = useState(true);
|
||||||
|
const [errorUser, setErrorUser] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Для списка тайтлов
|
||||||
|
const [titles, setTitles] = useState<UserTitle[]>([]);
|
||||||
|
const [nextPage, setNextPage] = useState<UserTitle[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<CursorObj | null>(null);
|
||||||
|
const [loadingTitles, setLoadingTitles] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sort, setSort] = useState<TitleSort>("id");
|
||||||
|
const [sortForward, setSortForward] = useState(true);
|
||||||
|
const [layout, setLayout] = useState<"square" | "horizontal">("square");
|
||||||
|
|
||||||
|
// --- Получение данных пользователя ---
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUser = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoadingUser(true);
|
||||||
|
try {
|
||||||
|
const result = await DefaultService.getUsersId(id, "all");
|
||||||
|
setUser(result);
|
||||||
|
setErrorUser(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setErrorUser(err?.message || "Failed to fetch user data");
|
||||||
|
} finally {
|
||||||
|
setLoadingUser(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchUser();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// --- Получение списка тайтлов пользователя ---
|
||||||
|
const fetchPage = async (cursorObj: CursorObj | null) => {
|
||||||
|
if (!id) return { items: [], nextCursor: null };
|
||||||
|
const cursorStr = cursorObj
|
||||||
|
? btoa(JSON.stringify(cursorObj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await DefaultService.getUserTitles(
|
||||||
|
id,
|
||||||
|
cursorStr,
|
||||||
|
sort,
|
||||||
|
sortForward,
|
||||||
|
search.trim() || undefined,
|
||||||
|
undefined, // status фильтр, можно добавить
|
||||||
|
undefined, // watchStatus
|
||||||
|
undefined, // rating
|
||||||
|
undefined, // myRate
|
||||||
|
undefined, // releaseYear
|
||||||
|
undefined, // releaseSeason
|
||||||
|
PAGE_SIZE,
|
||||||
|
"all"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.data?.length) return { items: [], nextCursor: null };
|
||||||
|
|
||||||
|
return { items: result.data, nextCursor: result.cursor ?? null };
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.status === 204) return { items: [], nextCursor: null };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инициализация: загружаем сразу две страницы
|
||||||
|
useEffect(() => {
|
||||||
|
const initLoad = async () => {
|
||||||
|
setLoadingTitles(true);
|
||||||
|
setTitles([]);
|
||||||
|
setNextPage([]);
|
||||||
|
setCursor(null);
|
||||||
|
|
||||||
|
const firstPage = await fetchPage(null);
|
||||||
|
const secondPage = firstPage.nextCursor ? await fetchPage(firstPage.nextCursor) : { items: [], nextCursor: null };
|
||||||
|
|
||||||
|
setTitles(firstPage.items);
|
||||||
|
setNextPage(secondPage.items);
|
||||||
|
setCursor(secondPage.nextCursor);
|
||||||
|
setLoadingTitles(false);
|
||||||
|
};
|
||||||
|
initLoad();
|
||||||
|
}, [id, search, sort, sortForward]);
|
||||||
|
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
if (nextPage.length === 0) {
|
||||||
|
setLoadingMore(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingMore(true);
|
||||||
|
|
||||||
|
setTitles(prev => [...prev, ...nextPage]);
|
||||||
|
setNextPage([]);
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
try {
|
||||||
|
const next = await fetchPage(cursor);
|
||||||
|
if (next.items.length > 0) setNextPage(next.items);
|
||||||
|
setCursor(next.nextCursor);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingMore(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen bg-gray-50 p-6 flex flex-col items-center">
|
||||||
|
|
||||||
|
{/* --- Карточка пользователя --- */}
|
||||||
|
{loadingUser && <div className="mt-10 text-xl font-medium">Loading user...</div>}
|
||||||
|
{errorUser && <div className="mt-10 text-red-600 font-medium">{errorUser}</div>}
|
||||||
|
{user && (
|
||||||
|
<div className="bg-white shadow-lg rounded-xl p-6 w-full max-w-sm flex flex-col items-center mb-8">
|
||||||
|
<img src={user.image?.image_path} alt={user.nickname} className="w-32 h-32 rounded-full object-cover mb-4" />
|
||||||
|
<h2 className="text-2xl font-bold mb-2">{user.disp_name || user.nickname}</h2>
|
||||||
|
{user.mail && <p className="text-gray-600 mb-2">{user.mail}</p>}
|
||||||
|
{user.user_desc && <p className="text-gray-700 text-center">{user.user_desc}</p>}
|
||||||
|
{user.creation_date && <p className="text-gray-400 mt-4 text-sm">Registered: {new Date(user.creation_date).toLocaleDateString()}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Панель поиска, сортировки и лейаута --- */}
|
||||||
|
<div className="w-full sm:w-4/5 flex flex-col sm:flex-row gap-4 mb-6 items-center">
|
||||||
|
<SearchBar placeholder="Search titles..." search={search} setSearch={setSearch} />
|
||||||
|
<LayoutSwitch layout={layout} setLayout={setLayout} />
|
||||||
|
<TitlesSortBox sort={sort} setSort={setSort} sortForward={sortForward} setSortForward={setSortForward} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Список тайтлов --- */}
|
||||||
|
{loadingTitles && <div className="mt-6 font-medium text-black">Loading titles...</div>}
|
||||||
|
{!loadingTitles && titles.length === 0 && <div className="mt-6 font-medium text-black">No titles found.</div>}
|
||||||
|
|
||||||
|
{titles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ListView<UserTitle>
|
||||||
|
items={titles}
|
||||||
|
layout={layout}
|
||||||
|
hasMore={!!cursor || nextPage.length > 1}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
renderItem={(title, layout) => (
|
||||||
|
<Link to={`/titles/${title.title?.id}`} key={title.title?.id} className="block">
|
||||||
|
{layout === "square" ? <UserTitleCardSquare title={title} /> : <UserTitleCardHorizontal title={title} />}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!cursor && nextPage.length === 0 && (
|
||||||
|
<div className="mt-6 font-medium text-black">
|
||||||
|
Результатов больше нет, было найдено {titles.length} тайтлов.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
-- TODO:
|
|
||||||
-- maybe jsonb constraints
|
|
||||||
-- clean unused images
|
|
||||||
CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress');
|
CREATE TYPE 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');
|
||||||
|
|
@ -24,37 +21,24 @@ 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),
|
avatar_id bigint REFERENCES images (id) ON DELETE SET NULL,
|
||||||
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,
|
creation_date timestamptz NOT NULL DEFAULT NOW(),
|
||||||
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),
|
illust_id bigint REFERENCES images (id) ON DELETE SET NULL,
|
||||||
studio_desc text
|
studio_desc text
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -64,7 +48,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),
|
poster_id bigint REFERENCES images (id) ON DELETE SET NULL,
|
||||||
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),
|
||||||
|
|
@ -80,21 +64,36 @@ 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),
|
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
title_id bigint NOT NULL REFERENCES titles (id),
|
title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE,
|
||||||
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),
|
review_id bigint REFERENCES reviews (id) ON DELETE SET NULL,
|
||||||
ctime timestamptz
|
ctime timestamptz NOT NULL DEFAULT now()
|
||||||
-- // 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),
|
title_id bigint NOT NULL REFERENCES titles (id) ON DELETE CASCADE,
|
||||||
tag_id bigint NOT NULL REFERENCES tags (id)
|
tag_id bigint NOT NULL REFERENCES tags (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE signals (
|
CREATE TABLE signals (
|
||||||
|
|
@ -105,17 +104,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,4 +168,17 @@ EXECUTE FUNCTION update_title_rating();
|
||||||
CREATE TRIGGER trg_notify_new_signal
|
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();
|
||||||
|
|
@ -6,6 +6,7 @@ package sqlc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -223,11 +224,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 []byte `json:"raw_data"`
|
RawData json.RawMessage `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 {
|
||||||
|
|
@ -238,13 +239,13 @@ type Studio struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tag struct {
|
type Tag struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
TagNames []byte `json:"tag_names"`
|
TagNames json.RawMessage `json:"tag_names"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Title struct {
|
type Title struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
TitleNames []byte `json:"title_names"`
|
TitleNames json.RawMessage `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"`
|
||||||
|
|
@ -276,10 +277,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 pgtype.Timestamptz `json:"ctime"`
|
Ctime time.Time `json:"ctime"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ 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:
|
||||||
|
|
@ -12,7 +13,20 @@ 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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue