diff --git a/api/_build/oapi-codegen.yaml b/api/_build/oapi-codegen.yaml new file mode 100644 index 0000000..32e029a --- /dev/null +++ b/api/_build/oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: oapi +generate: + strict-server: true + gin-server: true + models: true +output: api/api.gen.go \ No newline at end of file diff --git a/api/_build/openapi.yaml b/api/_build/openapi.yaml new file mode 100644 index 0000000..5ff77e0 --- /dev/null +++ b/api/_build/openapi.yaml @@ -0,0 +1,436 @@ +openapi: 3.0.4 +info: + title: 'Titles, Users, Reviews, Tags, and Media API' + version: 1.0.0 +servers: + - url: /api/v1 +paths: + /titles: + get: + summary: Get titles + parameters: + - $ref: '#/components/parameters/cursor' + - $ref: '#/components/parameters/title_sort' + - in: query + name: sort_forward + schema: + type: boolean + default: true + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: offset + schema: + type: integer + format: int32 + default: 0 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of titles with cursor + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Title' + description: List of titles + cursor: + $ref: '#/components/schemas/CursorObj' + required: + - data + - cursor + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error + '/titles/{title_id}': + get: + summary: Get title description + parameters: + - in: path + name: title_id + required: true + schema: + type: integer + format: int64 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: Title description + content: + application/json: + schema: + $ref: '#/components/schemas/Title' + '204': + description: No title found + '400': + description: Request params are not correct + '404': + description: Title not found + '500': + description: Unknown server error + '/users/{user_id}': + get: + summary: Get user info + parameters: + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: User info + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Request params are not correct + '404': + description: User not found + '500': + description: Unknown server error + '/users/{user_id}/titles/': + get: + summary: Get user titles + parameters: + - $ref: '#/components/parameters/cursor' + - in: path + name: user_id + required: true + schema: + type: string + - in: query + name: word + schema: + type: string + - in: query + name: status + schema: + $ref: '#/components/schemas/TitleStatus' + - in: query + name: watch_status + schema: + $ref: '#/components/schemas/UserTitleStatus' + - in: query + name: rating + schema: + type: number + format: double + - in: query + name: release_year + schema: + type: integer + format: int32 + - in: query + name: release_season + schema: + $ref: '#/components/schemas/ReleaseSeason' + - in: query + name: limit + schema: + type: integer + format: int32 + default: 10 + - in: query + name: fields + schema: + type: string + default: all + responses: + '200': + description: List of user titles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserTitle' + '204': + description: No titles found + '400': + description: Request params are not correct + '500': + description: Unknown server error +components: + parameters: + cursor: + in: query + name: cursor + required: false + schema: + type: string + title_sort: + in: query + name: sort + required: false + schema: + $ref: '#/components/schemas/TitleSort' + schemas: + CursorObj: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + param: + type: string + TitleSort: + type: string + description: Title sort order + default: id + enum: + - id + - year + - rating + - views + Image: + type: object + properties: + id: + type: integer + format: int64 + storage_type: + type: string + image_path: + type: string + TitleStatus: + type: string + description: Title status + enum: + - finished + - ongoing + - planned + ReleaseSeason: + type: string + description: Title release season + enum: + - winter + - spring + - summer + - fall + UserTitleStatus: + type: string + description: User's title status + enum: + - finished + - planned + - dropped + - in-progress + Review: + type: object + additionalProperties: true + Tag: + type: object + description: 'A localized tag: keys are language codes (ISO 639-1), values are tag names' + additionalProperties: + type: string + example: + en: Shojo + ru: Сёдзё + ja: 少女 + Tags: + type: array + description: Array of localized tags + items: + $ref: '#/components/schemas/Tag' + example: + - en: Shojo + ru: Сёдзё + ja: 少女 + - en: Shounen + ru: Сёнен + ja: 少年 + Studio: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + poster: + $ref: '#/components/schemas/Image' + description: + type: string + Title: + type: object + required: + - id + - title_names + - tags + properties: + id: + type: integer + format: int64 + description: Unique title ID (primary key) + example: 1 + title_names: + type: object + description: 'Localized titles. Key = language (ISO 639-1), value = list of names' + additionalProperties: + type: array + items: + type: string + example: Attack on Titan + minItems: 1 + example: + - Attack on Titan + - AoT + example: + en: + - Attack on Titan + - AoT + ru: + - Атака титанов + - Титаны + ja: + - 進撃の巨人 + studio: + $ref: '#/components/schemas/Studio' + tags: + $ref: '#/components/schemas/Tags' + poster: + $ref: '#/components/schemas/Image' + title_status: + $ref: '#/components/schemas/TitleStatus' + rating: + type: number + format: double + rating_count: + type: integer + format: int32 + release_year: + type: integer + format: int32 + release_season: + $ref: '#/components/schemas/ReleaseSeason' + episodes_aired: + type: integer + format: int32 + episodes_all: + type: integer + format: int32 + episodes_len: + type: object + additionalProperties: + type: number + format: double + additionalProperties: true + User: + type: object + properties: + id: + type: integer + format: int64 + description: Unique user ID (primary key) + example: 1 + avatar_id: + type: integer + format: int64 + description: ID of the user avatar (references images table) + example: null + mail: + type: string + format: email + description: User email + example: john.doe@example.com + nickname: + type: string + description: Username (alphanumeric + _ or -) + maxLength: 16 + example: john_doe_42 + disp_name: + type: string + description: Display name + maxLength: 32 + example: John Doe + user_desc: + type: string + description: User description + maxLength: 512 + example: Just a regular user. + creation_date: + type: string + format: date-time + description: Timestamp when the user was created + example: '2025-10-10T23:45:47.908073Z' + required: + - user_id + - nickname + UserTitle: + type: object + required: + - user_id + - title_id + - status + properties: + user_id: + type: integer + format: int64 + title_id: + type: integer + format: int64 + status: + $ref: '#/components/schemas/UserTitleStatus' + rate: + type: integer + format: int32 + review_id: + type: integer + format: int64 + ctime: + type: string + format: date-time + additionalProperties: true diff --git a/api/api.gen.go b/api/api.gen.go index 427f5af..f252a5a 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -47,6 +47,12 @@ const ( UserTitleStatusPlanned UserTitleStatus = "planned" ) +// CursorObj defines model for CursorObj. +type CursorObj struct { + Id int64 `json:"id"` + Param *string `json:"param,omitempty"` +} + // Image defines model for Image. type Image struct { Id *int64 `json:"id,omitempty"` @@ -108,7 +114,7 @@ type TitleStatus string // User defines model for User. type User struct { // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id"` + AvatarId *int64 `json:"avatar_id,omitempty"` // CreationDate Timestamp when the user was created CreationDate *time.Time `json:"creation_date,omitempty"` @@ -906,7 +912,12 @@ type GetTitlesResponseObject interface { VisitGetTitlesResponse(w http.ResponseWriter) error } -type GetTitles200JSONResponse []Title +type GetTitles200JSONResponse struct { + Cursor CursorObj `json:"cursor"` + + // Data List of titles + Data []Title `json:"data"` +} func (response GetTitles200JSONResponse) VisitGetTitlesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/api/paths/titles.yaml b/api/paths/titles.yaml index 4fd010d..e868ed6 100644 --- a/api/paths/titles.yaml +++ b/api/paths/titles.yaml @@ -1,8 +1,8 @@ get: summary: Get titles parameters: - - $ref: ../parameters/cursor.yaml - - $ref: ../parameters/title_sort.yaml + - $ref: "../parameters/cursor.yaml" + - $ref: "../parameters/title_sort.yaml" - in: query name: sort_forward schema: diff --git a/modules/backend/handlers/titles.go b/modules/backend/handlers/titles.go index 46ff982..e8a3bff 100644 --- a/modules/backend/handlers/titles.go +++ b/modules/backend/handlers/titles.go @@ -218,6 +218,9 @@ func (s Server) GetTitlesTitleId(ctx context.Context, request oapi.GetTitlesTitl func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObject) (oapi.GetTitlesResponseObject, error) { opai_titles := make([]oapi.Title, 0) + cursor := oapi.CursorObj{ + Id: 1, + } word := Word2Sqlc(request.Params.Word) status, err := TitleStatus2Sqlc(request.Params.Status) @@ -237,7 +240,6 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje Rating: request.Params.Rating, ReleaseYear: request.Params.ReleaseYear, ReleaseSeason: season, - Offset: request.Params.Offset, Limit: request.Params.Limit, }) if err != nil { @@ -258,5 +260,5 @@ func (s Server) GetTitles(ctx context.Context, request oapi.GetTitlesRequestObje opai_titles = append(opai_titles, t) } - return oapi.GetTitles200JSONResponse(opai_titles), nil + return oapi.GetTitles200JSONResponse{Cursor: cursor, Data: opai_titles}, nil } diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 423be37..8962895 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -107,9 +107,67 @@ WHERE AND (sqlc.narg('rating')::float IS NULL OR rating >= sqlc.narg('rating')::float) AND (sqlc.narg('release_year')::int IS NULL OR release_year = sqlc.narg('release_year')::int) AND (sqlc.narg('release_season')::release_season_t IS NULL OR release_season = sqlc.narg('release_season')::release_season_t) +ORDER BY + -- Основной ключ: выбранное поле + CASE + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'year' THEN release_year + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'rating' THEN rating + WHEN sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'views' THEN views + END ASC, + CASE + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'id' THEN id + WHEN NOT sqlc.arg(forward)::boolean AND sqlc.arg(sort_by)::text = 'name' THEN name + 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, -LIMIT COALESCE(sqlc.narg('limit')::int, 100) -- 100 is default limit -OFFSET sqlc.narg('offset')::int; + -- Вторичный ключ: 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 + ) + 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('rating')::float IS NULL OR t.rating >= sqlc.narg('rating')::float) + AND (sqlc.narg('release_year')::int IS NULL OR t.release_year = sqlc.narg('release_year')::int) + AND (sqlc.narg('release_season')::release_season_t IS NULL OR t.release_season = sqlc.narg('release_season')::release_season_t) + AND (sqlc.narg('usertitle_status')::usertitle_status_t IS NULL OR u.usertitle_status = sqlc.narg('usertitle_status')::usertitle_status_t) + +LIMIT COALESCE(sqlc.narg('limit')::int, 100); -- 100 is default limit -- -- name: ListTitles :many -- SELECT title_id, title_names, studio_id, poster_id, signal_ids, diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 49cca3d..e6ed628 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -59,7 +59,7 @@ CREATE TABLE studios ( ); CREATE TABLE titles ( - // TODO: anime type (film, season etc) + -- // TODO: anime type (film, season etc) id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- example {"ru": ["Атака титанов", "Атака Титанов"],"en": ["Attack on Titan", "AoT"],"ja": ["進撃の巨人", "しんげきのきょじん"]} title_names jsonb NOT NULL, diff --git a/sql/models.go b/sql/models.go index a593504..93cecca 100644 --- a/sql/models.go +++ b/sql/models.go @@ -212,7 +212,6 @@ type Review struct { ID int64 `json:"id"` Data string `json:"data"` Rating *int32 `json:"rating"` - IllustID *int64 `json:"illust_id"` UserID *int64 `json:"user_id"` TitleID *int64 `json:"title_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` @@ -277,10 +276,10 @@ type User struct { } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewText *string `json:"review_text"` - ReviewDate pgtype.Timestamptz `json:"review_date"` + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index c5e6f8a..4e28f40 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -8,6 +8,8 @@ package sqlc import ( "context" "time" + + "github.com/jackc/pgx/v5/pgtype" ) const createImage = `-- name: CreateImage :one @@ -31,7 +33,7 @@ func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image const getImageByID = `-- name: GetImageByID :one SELECT id, storage_type, image_path FROM images -WHERE id = $1 +WHERE id = $1::bigint ` func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, error) { @@ -44,11 +46,13 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro const getReviewByID = `-- name: GetReviewByID :one -SELECT id, data, rating, illust_id, user_id, title_id, created_at + +SELECT id, data, rating, user_id, title_id, created_at FROM reviews WHERE review_id = $1::bigint ` +// 100 is default limit // -- name: ListTitles :many // SELECT title_id, title_names, studio_id, poster_id, signal_ids, // @@ -82,7 +86,6 @@ func (q *Queries) GetReviewByID(ctx context.Context, reviewID int64) (Review, er &i.ID, &i.Data, &i.Rating, - &i.IllustID, &i.UserID, &i.TitleID, &i.CreatedAt, @@ -312,9 +315,18 @@ WHERE AND ($3::float IS NULL OR rating >= $3::float) AND ($4::int IS NULL OR release_year = $4::int) AND ($5::release_season_t IS NULL OR release_season = $5::release_season_t) - -LIMIT COALESCE($7::int, 100) -- 100 is default limit -OFFSET $6::int +ORDER BY CASE + WHEN $6::boolean AND $7::text = 'name' THEN name + WHEN $8::boolean AND $7::text = 'id' THEN id + WHEN $8::boolean AND $7::text = 'name' THEN name + WHEN $8::boolean AND $7::text = 'id' THEN id +END ASC, CASE + WHEN NOT $8::boolean AND $7::text = 'name' THEN name + WHEN NOT $8::boolean AND $7::text = 'id' THEN id + WHEN NOT $8::boolean AND $7::text = 'name' THEN name + WHEN NOT $8::boolean AND $7::text = 'id' THEN id +END DESC +LIMIT COALESCE($9::int, 100) ` type SearchTitlesParams struct { @@ -323,7 +335,9 @@ type SearchTitlesParams struct { Rating *float64 `json:"rating"` ReleaseYear *int32 `json:"release_year"` ReleaseSeason *ReleaseSeasonT `json:"release_season"` - Offset *int32 `json:"offset"` + Forward bool `json:"forward"` + OrderBy string `json:"order_by"` + Reverse bool `json:"reverse"` Limit *int32 `json:"limit"` } @@ -334,7 +348,9 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T arg.Rating, arg.ReleaseYear, arg.ReleaseSeason, - arg.Offset, + arg.Forward, + arg.OrderBy, + arg.Reverse, arg.Limit, ) if err != nil { @@ -368,3 +384,122 @@ func (q *Queries) SearchTitles(ctx context.Context, arg SearchTitlesParams) ([]T } return items, nil } + +const searchUserTitles = `-- name: SearchUserTitles :many + +SELECT + user_id, title_id, status, rate, review_id, ctime, id, title_names, studio_id, poster_id, title_status, rating, rating_count, release_year, release_season, season, episodes_aired, episodes_all, episodes_len +FROM usertitles as u +JOIN titles as t ON (u.title_id = t.id) +WHERE + CASE + WHEN $1::text IS NOT NULL THEN + ( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM jsonb_each_text(t.title_names) AS t(key, val) + WHERE val ILIKE pattern + ) + ) + FROM unnest( + ARRAY( + SELECT '%' || trim(w) || '%' + FROM unnest(string_to_array($1::text, ' ')) AS w + WHERE trim(w) <> '' + ) + ) AS pattern + ) + ELSE true + END + + AND ($2::title_status_t IS NULL OR t.title_status = $2::title_status_t) + AND ($3::float IS NULL OR t.rating >= $3::float) + AND ($4::int IS NULL OR t.release_year = $4::int) + AND ($5::release_season_t IS NULL OR t.release_season = $5::release_season_t) + AND ($6::usertitle_status_t IS NULL OR u.usertitle_status = $6::usertitle_status_t) + +LIMIT COALESCE($7::int, 100) +` + +type SearchUserTitlesParams struct { + Word *string `json:"word"` + Status *TitleStatusT `json:"status"` + Rating *float64 `json:"rating"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + UsertitleStatus NullUsertitleStatusT `json:"usertitle_status"` + Limit *int32 `json:"limit"` +} + +type SearchUserTitlesRow struct { + UserID int64 `json:"user_id"` + TitleID int64 `json:"title_id"` + Status UsertitleStatusT `json:"status"` + Rate *int32 `json:"rate"` + ReviewID *int64 `json:"review_id"` + Ctime pgtype.Timestamptz `json:"ctime"` + ID int64 `json:"id"` + TitleNames []byte `json:"title_names"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` + TitleStatus TitleStatusT `json:"title_status"` + Rating *float64 `json:"rating"` + RatingCount *int32 `json:"rating_count"` + ReleaseYear *int32 `json:"release_year"` + ReleaseSeason *ReleaseSeasonT `json:"release_season"` + Season *int32 `json:"season"` + EpisodesAired *int32 `json:"episodes_aired"` + EpisodesAll *int32 `json:"episodes_all"` + EpisodesLen []byte `json:"episodes_len"` +} + +// 100 is default limit +// OFFSET sqlc.narg('offset')::int; +func (q *Queries) SearchUserTitles(ctx context.Context, arg SearchUserTitlesParams) ([]SearchUserTitlesRow, error) { + rows, err := q.db.Query(ctx, searchUserTitles, + arg.Word, + arg.Status, + arg.Rating, + arg.ReleaseYear, + arg.ReleaseSeason, + arg.UsertitleStatus, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchUserTitlesRow + for rows.Next() { + var i SearchUserTitlesRow + if err := rows.Scan( + &i.UserID, + &i.TitleID, + &i.Status, + &i.Rate, + &i.ReviewID, + &i.Ctime, + &i.ID, + &i.TitleNames, + &i.StudioID, + &i.PosterID, + &i.TitleStatus, + &i.Rating, + &i.RatingCount, + &i.ReleaseYear, + &i.ReleaseSeason, + &i.Season, + &i.EpisodesAired, + &i.EpisodesAll, + &i.EpisodesLen, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +}