Compare commits

..

No commits in common. "e12812d202aa31ba565100400f2a14312a302e86" and "db53ae04e39bff6e625963be87809d798263e827" have entirely different histories.

15 changed files with 1046 additions and 559 deletions

View file

@ -8,48 +8,28 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/oapi-codegen/runtime" "github.com/oapi-codegen/runtime"
strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin"
openapi_types "github.com/oapi-codegen/runtime/types"
) )
// User defines model for User. // Title defines model for Title.
type User struct { type Title map[string]interface{}
// AvatarId ID of the user avatar (references images table)
AvatarId *int64 `json:"avatar_id"`
// CreationDate Timestamp when the user was created // GetTitleParams defines parameters for GetTitle.
CreationDate time.Time `json:"creation_date"` type GetTitleParams struct {
Query *string `form:"query,omitempty" json:"query,omitempty"`
// DispName Display name Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
DispName *string `json:"disp_name,omitempty"` Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
// Id Unique user ID (primary key)
Id *int64 `json:"id,omitempty"`
// Mail User email
Mail *openapi_types.Email `json:"mail,omitempty"`
// Nickname Username (alphanumeric + _ or -)
Nickname string `json:"nickname"`
// UserDesc User description
UserDesc *string `json:"user_desc,omitempty"`
}
// GetUsersUserIdParams defines parameters for GetUsersUserId.
type GetUsersUserIdParams struct {
Fields *string `form:"fields,omitempty" json:"fields,omitempty"` Fields *string `form:"fields,omitempty" json:"fields,omitempty"`
} }
// ServerInterface represents all server handlers. // ServerInterface represents all server handlers.
type ServerInterface interface { type ServerInterface interface {
// Get user info // Get titles
// (GET /users/{user_id}) // (GET /title)
GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) GetTitle(c *gin.Context, params GetTitleParams)
} }
// ServerInterfaceWrapper converts contexts to parameters. // ServerInterfaceWrapper converts contexts to parameters.
@ -61,22 +41,37 @@ type ServerInterfaceWrapper struct {
type MiddlewareFunc func(c *gin.Context) type MiddlewareFunc func(c *gin.Context)
// GetUsersUserId operation middleware // GetTitle operation middleware
func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) {
var err error var err error
// ------------- Path parameter "user_id" ------------- // Parameter object where we will unmarshal all parameters from the context
var userId string var params GetTitleParams
err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) // ------------- Optional query parameter "query" -------------
err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), &params.Query)
if err != nil { if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest)
return return
} }
// Parameter object where we will unmarshal all parameters from the context // ------------- Optional query parameter "limit" -------------
var params GetUsersUserIdParams
err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), &params.Limit)
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter limit: %w", err), http.StatusBadRequest)
return
}
// ------------- Optional query parameter "offset" -------------
err = runtime.BindQueryParameter("form", true, false, "offset", c.Request.URL.Query(), &params.Offset)
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest)
return
}
// ------------- Optional query parameter "fields" ------------- // ------------- Optional query parameter "fields" -------------
@ -93,7 +88,7 @@ func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) {
} }
} }
siw.Handler.GetUsersUserId(c, userId, params) siw.Handler.GetTitle(c, params)
} }
// GinServerOptions provides options for the Gin server. // GinServerOptions provides options for the Gin server.
@ -123,40 +118,39 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options
ErrorHandler: errorHandler, ErrorHandler: errorHandler,
} }
router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) router.GET(options.BaseURL+"/title", wrapper.GetTitle)
} }
type GetUsersUserIdRequestObject struct { type GetTitleRequestObject struct {
UserId string `json:"user_id"` Params GetTitleParams
Params GetUsersUserIdParams
} }
type GetUsersUserIdResponseObject interface { type GetTitleResponseObject interface {
VisitGetUsersUserIdResponse(w http.ResponseWriter) error VisitGetTitleResponse(w http.ResponseWriter) error
} }
type GetUsersUserId200JSONResponse User type GetTitle200JSONResponse []Title
func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200) w.WriteHeader(200)
return json.NewEncoder(w).Encode(response) return json.NewEncoder(w).Encode(response)
} }
type GetUsersUserId404Response struct { type GetTitle204Response struct {
} }
func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error {
w.WriteHeader(404) w.WriteHeader(204)
return nil return nil
} }
// StrictServerInterface represents all server handlers. // StrictServerInterface represents all server handlers.
type StrictServerInterface interface { type StrictServerInterface interface {
// Get user info // Get titles
// (GET /users/{user_id}) // (GET /title)
GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error)
} }
type StrictHandlerFunc = strictgin.StrictGinHandlerFunc type StrictHandlerFunc = strictgin.StrictGinHandlerFunc
@ -171,18 +165,17 @@ type strictHandler struct {
middlewares []StrictMiddlewareFunc middlewares []StrictMiddlewareFunc
} }
// GetUsersUserId operation middleware // GetTitle operation middleware
func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) {
var request GetUsersUserIdRequestObject var request GetTitleRequestObject
request.UserId = userId
request.Params = params request.Params = params
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject))
} }
for _, middleware := range sh.middlewares { for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetUsersUserId") handler = middleware(handler, "GetTitle")
} }
response, err := handler(ctx, request) response, err := handler(ctx, request)
@ -190,8 +183,8 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params
if err != nil { if err != nil {
ctx.Error(err) ctx.Error(err)
ctx.Status(http.StatusInternalServerError) ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { } else if validResponse, ok := response.(GetTitleResponseObject); ok {
if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil {
ctx.Error(err) ctx.Error(err)
} }
} else if response != nil { } else if response != nil {

View file

@ -5,40 +5,40 @@ info:
servers: servers:
- url: https://api.example.com - url: https://api.example.com
paths: paths:
# /title: /title:
# get: get:
# summary: Get titles summary: Get titles
# parameters: parameters:
# - in: query - in: query
# name: query name: query
# schema: schema:
# type: string type: string
# - in: query - in: query
# name: limit name: limit
# schema: schema:
# type: integer type: integer
# default: 10 default: 10
# - in: query - in: query
# name: offset name: offset
# schema: schema:
# type: integer type: integer
# default: 0 default: 0
# - in: query - in: query
# name: fields name: fields
# schema: schema:
# type: string type: string
# default: all default: all
# responses: responses:
# '200': '200':
# description: List of titles description: List of titles
# content: content:
# application/json: application/json:
# schema: schema:
# type: array type: array
# items: items:
# $ref: '#/components/schemas/Title' $ref: '#/components/schemas/Title'
# '204': '204':
# description: No titles found description: No titles found
# /title/{title_id}: # /title/{title_id}:
# get: # get:
@ -124,122 +124,122 @@ paths:
# '204': # '204':
# description: No reviews found # description: No reviews found
/users/{user_id}: # /users/{user_id}:
get: # get:
summary: Get user info # summary: Get user info
parameters: # parameters:
- in: path # - in: path
name: user_id # name: user_id
required: true # required: true
schema: # schema:
type: string # type: string
- in: query # - in: query
name: fields # name: fields
schema: # schema:
type: string # type: string
default: all # default: all
responses: # responses:
'200': # '200':
description: User info # description: User info
content: # content:
application/json: # application/json:
schema: # schema:
$ref: '#/components/schemas/User' # $ref: '#/components/schemas/User'
'404': # '404':
description: User not found # description: User not found
# patch: # patch:
# summary: Update user # summary: Update user
# parameters: # parameters:
# - in: path # - in: path
# name: user_id # name: user_id
# required: true # required: true
# schema: # schema:
# type: string # type: string
# requestBody: # requestBody:
# required: true # required: true
# content: # content:
# application/json: # application/json:
# schema: # schema:
# $ref: '#/components/schemas/User' # $ref: '#/components/schemas/User'
# responses: # responses:
# '200': # '200':
# description: Update result # description: Update result
# content: # content:
# application/json: # application/json:
# schema: # schema:
# type: object # type: object
# properties: # properties:
# success: # success:
# type: boolean # type: boolean
# error: # error:
# type: string # type: string
# delete: # delete:
# summary: Delete user # summary: Delete user
# parameters: # parameters:
# - in: path # - in: path
# name: user_id # name: user_id
# required: true # required: true
# schema: # schema:
# type: string # type: string
# responses: # responses:
# '200': # '200':
# description: Delete result # description: Delete result
# content: # content:
# application/json: # application/json:
# schema: # schema:
# type: object # type: object
# properties: # properties:
# success: # success:
# type: boolean # type: boolean
# error: # error:
# type: string # type: string
# /users: # /users:
# get: # get:
# summary: Search user # summary: Search user
# parameters: # parameters:
# - in: query # - in: query
# name: query # name: query
# schema: # schema:
# type: string # type: string
# - in: query # - in: query
# name: fields # name: fields
# schema: # schema:
# type: string # type: string
# responses: # responses:
# '200': # '200':
# description: List of users # description: List of users
# content: # content:
# application/json: # application/json:
# schema: # schema:
# type: array # type: array
# items: # items:
# $ref: '#/components/schemas/User' # $ref: '#/components/schemas/User'
# post: # post:
# summary: Add new user # summary: Add new user
# requestBody: # requestBody:
# required: true # required: true
# content: # content:
# application/json: # application/json:
# schema: # schema:
# $ref: '#/components/schemas/User' # $ref: '#/components/schemas/User'
# responses: # responses:
# '200': # '200':
# description: Add result # description: Add result
# content: # content:
# application/json: # application/json:
# schema: # schema:
# type: object # type: object
# properties: # properties:
# success: # success:
# type: boolean # type: boolean
# error: # error:
# type: string # type: string
# user_json: # user_json:
# $ref: '#/components/schemas/User' # $ref: '#/components/schemas/User'
# /users/{user_id}/titles: # /users/{user_id}/titles:
# get: # get:
@ -541,14 +541,14 @@ components:
User: User:
type: object type: object
properties: properties:
id: user_id:
type: integer type: integer
format: int64 format: int32
description: Unique user ID (primary key) description: Unique user ID (primary key)
example: 1 example: 1
avatar_id: avatar_id:
type: integer type: integer
format: int64 format: int32
description: ID of the user avatar (references images table) description: ID of the user avatar (references images table)
nullable: true nullable: true
example: null example: null

719
modules/backend/handlers.go Normal file
View file

@ -0,0 +1,719 @@
package main
import (
"context"
"encoding/json"
"nyanimedb/modules/backend/db"
sqlc "nyanimedb/sql"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt"
)
type Server struct {
db *sqlc.Queries
}
func NewServer(db *db.Queries) Server {
return Server{db: db}
}
// —————————————————————————————————————————————
// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
// —————————————————————————————————————————————
func parseInt32(s string) (int32, error) {
i, err := strconv.ParseInt(s, 10, 32)
return int32(i), err
}
func ptr[T any](v T) *T { return &v }
func pgInt4ToPtr(v pgtype.Int4) *int32 {
if v.Valid {
return &v.Int32
}
return nil
}
func pgTextToPtr(v pgtype.Text) *string {
if v.Valid {
return &v.String
}
return nil
}
func pgFloat8ToPtr(v pgtype.Float8) *float64 {
if v.Valid {
return &v.Float64
}
return nil
}
func jsonbToInterface(data []byte) interface{} {
if data == nil {
return nil
}
var out interface{}
if err := json.Unmarshal(data, &out); err != nil {
return string(data) // fallback
}
return out
}
// —————————————————————————————————————————————
// ХЕНДЛЕРЫ
// —————————————————————————————————————————————
func (s Server) GetMedia(ctx context.Context, req GetMediaRequestObject) (GetMediaResponseObject, error) {
id, err := parseInt32(req.Params.ImageId)
if err != nil {
return GetMedia200JSONResponse{Success: ptr(false), Error: ptr("invalid image_id")}, nil
}
img, err := s.db.GetImageByID(ctx, id)
if err != nil {
if err == pgx.ErrNoRows {
return GetMedia200JSONResponse{Success: ptr(false), Error: ptr("image not found")}, nil
}
return nil, err
}
return GetMedia200JSONResponse{
Success: ptr(true),
ImagePath: ptr(img.ImagePath),
}, nil
}
func (s Server) PostMedia(ctx context.Context, req PostMediaRequestObject) (PostMediaResponseObject, error) {
// ❗ Не реализовано: OpenAPI не определяет тело запроса для загрузки
return PostMedia200JSONResponse{
Success: ptr(false),
Error: ptr("upload not implemented: request body not defined in spec"),
}, nil
}
func (s Server) GetUsers(ctx context.Context, req GetUsersRequestObject) (GetUsersResponseObject, error) {
users, err := s.db.ListUsers(ctx, db.ListUsersParams{})
if err != nil {
return nil, err
}
var resp []User
for _, u := range users {
resp = append(resp, mapUser(u))
}
return GetUsers200JSONResponse(resp), nil
}
func (s Server) PostUsers(ctx context.Context, req PostUsersRequestObject) (PostUsersResponseObject, error) {
if req.Body == nil {
return PostUsers200JSONResponse{
Success: ptr(false),
Error: ptr("request body is required"),
}, nil
}
body := *req.Body
// Обязательные поля
nickname, ok := body["nickname"].(string)
if !ok || nickname == "" {
return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("nickname is required")}, nil
}
mail, ok := body["mail"].(string)
if !ok || mail == "" {
return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("mail is required")}, nil
}
password, ok := body["password"].(string)
if !ok || password == "" {
return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("password is required")}, nil
}
// Опциональные поля
var avatarID *int32
if v, ok := body["avatar_id"].(float64); ok {
id := int32(v)
avatarID = &id
}
dispName, _ := body["disp_name"].(string)
userDesc, _ := body["user_desc"].(string)
// 🔐 Хешируем пароль
passhashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("failed to hash password")}, nil
}
passhash := string(passhashBytes)
// Сохраняем в БД
_, err = s.db.CreateUser(ctx, db.CreateUserParams{
AvatarID: pgtype.Int4{
Int32: 0,
Valid: avatarID != nil,
},
Passhash: passhash,
Mail: pgtype.Text{
String: mail,
Valid: true,
},
Nickname: nickname,
DispName: pgtype.Text{
String: dispName,
Valid: dispName != "",
},
UserDesc: pgtype.Text{
String: userDesc,
Valid: userDesc != "",
},
CreationDate: pgtype.Timestamp{
Time: time.Now(),
Valid: true,
},
})
if err != nil {
// Проверяем нарушение уникальности (например, дубль mail или nickname)
if err.Error() == "ERROR: duplicate key value violates unique constraint \"users_mail_key\"" ||
err.Error() == "ERROR: duplicate key value violates unique constraint \"users_nickname_key\"" {
return PostUsers200JSONResponse{
Success: ptr(false),
Error: ptr("user with this email or nickname already exists"),
}, nil
}
return PostUsers200JSONResponse{Success: ptr(false), Error: ptr("database error")}, nil
}
// Получаем созданного пользователя (без passhash и mail!)
// Предположим, что у вас есть запрос GetUserByNickname или аналогичный
// Но проще — вернуть только ID и nickname
// ⚠️ Поскольку мы не знаем user_id, можно:
// а) добавить RETURNING в CreateUser (рекомендуется),
// б) сделать отдельный SELECT.
// Пока вернём минимальный ответ
userResp := User{
"nickname": nickname,
// "user_id" можно добавить, если обновите query.sql
}
return PostUsers200JSONResponse{
Success: ptr(true),
UserJson: &userResp,
}, nil
}
func (s Server) DeleteUsersUserId(ctx context.Context, req DeleteUsersUserIdRequestObject) (DeleteUsersUserIdResponseObject, error) {
userID, err := parseInt32(req.UserId)
if err != nil {
return DeleteUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil
}
err = s.db.DeleteUser(ctx, userID)
if err != nil {
if err == pgx.ErrNoRows {
return DeleteUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("user not found")}, nil
}
return nil, err
}
return DeleteUsersUserId200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) GetUsersUserId(ctx context.Context, req GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) {
userID, err := parseInt32(req.UserId)
if err != nil {
return GetUsersUserId404Response{}, nil
}
user, err := s.db.GetUserByID(ctx, userID)
if err != nil {
if err == pgx.ErrNoRows {
return GetUsersUserId404Response{}, nil
}
return nil, err
}
return GetUsersUserId200JSONResponse(mapUser(user)), nil
}
func (s Server) PatchUsersUserId(ctx context.Context, req PatchUsersUserIdRequestObject) (PatchUsersUserIdResponseObject, error) {
userID, err := parseInt32(req.UserId)
if err != nil {
return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil
}
if req.Body == nil {
return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil
}
body := *req.Body
args := db.UpdateUserParams{
UserID: userID,
}
if v, ok := body["avatar_id"].(float64); ok {
args.AvatarID = pgtype.Int4{Int32: int32(v), Valid: true}
// args.AvatarIDValid = true
}
if v, ok := body["disp_name"].(string); ok {
args.DispName = pgtype.Text{String: v, Valid: true}
// args.DispNameValid = true
}
if v, ok := body["user_desc"].(string); ok {
args.UserDesc = pgtype.Text{String: v, Valid: true}
// args.UserDescValid = true
}
_, err = s.db.UpdateUser(ctx, args)
if err != nil {
return PatchUsersUserId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil
}
return PatchUsersUserId200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) GetUsersUserIdReviews(ctx context.Context, req GetUsersUserIdReviewsRequestObject) (GetUsersUserIdReviewsResponseObject, error) {
userID, err := parseInt32(req.UserId)
if err != nil {
return GetUsersUserIdReviews200JSONResponse{}, nil
}
limit := int32(20)
offset := int32(0)
// if req.Params.Limit != nil {
// limit = int32(*req.Params.Limit)
// }
// if req.Params.Offset != nil {
// offset = int32(*req.Params.Offset)
// }
reviews, err := s.db.ListReviewsByUser(ctx, db.ListReviewsByUserParams{
UserID: userID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, err
}
var resp []Review
for _, r := range reviews {
resp = append(resp, mapReview(r))
}
return GetUsersUserIdReviews200JSONResponse(resp), nil
}
func (s Server) DeleteUsersUserIdTitles(ctx context.Context, req DeleteUsersUserIdTitlesRequestObject) (DeleteUsersUserIdTitlesResponseObject, error) {
userID, err := parseInt32(req.UserId)
if err != nil {
return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil
}
if req.Params.TitleId != nil {
titleID, err := parseInt32(*req.Params.TitleId)
if err != nil {
return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil
}
err = s.db.DeleteUserTitle(ctx, db.DeleteUserTitleParams{
UserID: userID,
Column2: titleID,
})
if err != nil && err != pgx.ErrNoRows {
return nil, err
}
}
// else {
// err = s.db.DeleteAllUserTitles(ctx, userID)
// if err != nil {
// return nil, err
// }
// }
return DeleteUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) GetUsersUserIdTitles(ctx context.Context, req GetUsersUserIdTitlesRequestObject) (GetUsersUserIdTitlesResponseObject, error) {
userID, err := parseInt32(req.UserId)
if err != nil {
return GetUsersUserIdTitles200JSONResponse{}, nil
}
limit := int32(100)
offset := int32(0)
if req.Params.Limit != nil {
limit = int32(*req.Params.Limit)
}
if req.Params.Offset != nil {
offset = int32(*req.Params.Offset)
}
titles, err := s.db.ListUserTitles(ctx, db.ListUserTitlesParams{
UserID: userID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, err
}
var resp []UserTitle
for _, t := range titles {
resp = append(resp, mapUserTitle(t))
}
return GetUsersUserIdTitles200JSONResponse(resp), nil
}
func (s Server) PatchUsersUserIdTitles(ctx context.Context, req PatchUsersUserIdTitlesRequestObject) (PatchUsersUserIdTitlesResponseObject, error) {
// userID, err := parseInt32(req.UserId)
// if err != nil {
// return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil
// }
// if req.Body == nil {
// return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil
// }
// body := *req.Body
// titleID, ok := body["title_id"].(float64)
// if !ok {
// return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("title_id required")}, nil
// }
// args := db.UpdateUserTitleParams{
// UserID: userID,
// TitleID: int32(titleID),
// }
// if v, ok := body["status"].(string); ok {
// args.Status = db.UsertitleStatusT(v)
// // args.StatusValid = true
// }
// if v, ok := body["rate"].(float64); ok {
// args.Rate = pgtype.Int4{Int32: int32(v), Valid: true}
// // args.RateValid = true
// }
// if v, ok := body["review_id"].(float64); ok {
// args.ReviewID = pgtype.Int4{Int32: int32(v), Valid: true}
// // args.ReviewIDValid = true
// }
// _, err = s.db.UpdateUserTitle(ctx, args)
// if err != nil {
// return PatchUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil
// }
return PatchUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) PostUsersUserIdTitles(ctx context.Context, req PostUsersUserIdTitlesRequestObject) (PostUsersUserIdTitlesResponseObject, error) {
userID, err := parseInt32(req.UserId)
if err != nil {
return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid user_id")}, nil
}
if req.Body == nil {
return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil
}
body := req.Body
titleID, err := parseInt32(*body.TitleId)
if err != nil {
return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil
}
status := db.UsertitleStatusT("planned")
if body.Status != nil {
status = db.UsertitleStatusT(*body.Status)
}
_, err = s.db.CreateUserTitle(ctx, db.CreateUserTitleParams{
UserID: userID,
TitleID: titleID,
Status: status,
Rate: pgtype.Int4{Valid: false},
ReviewID: pgtype.Int4{Valid: false},
})
if err != nil {
return PostUsersUserIdTitles200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil
}
return PostUsersUserIdTitles200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) GetTags(ctx context.Context, req GetTagsRequestObject) (GetTagsResponseObject, error) {
limit := int32(100)
offset := int32(0)
if req.Params.Limit != nil {
limit = int32(*req.Params.Limit)
}
if req.Params.Offset != nil {
offset = int32(*req.Params.Offset)
}
tags, err := s.db.ListTags(ctx, db.ListTagsParams{Limit: limit, Offset: offset})
if err != nil {
return nil, err
}
var resp []Tag
for _, t := range tags {
resp = append(resp, Tag{
"tag_id": t.TagID,
"tag_names": jsonbToInterface(t.TagNames),
})
}
return GetTags200JSONResponse(resp), nil
}
func (s Server) GetTitle(ctx context.Context, req GetTitleRequestObject) (GetTitleResponseObject, error) {
limit := int32(50)
offset := int32(0)
if req.Params.Limit != nil {
limit = int32(*req.Params.Limit)
}
if req.Params.Offset != nil {
offset = int32(*req.Params.Offset)
}
titles, err := s.db.ListTitles(ctx, db.ListTitlesParams{Limit: limit, Offset: offset})
if err != nil {
return nil, err
}
var resp []Title
for _, t := range titles {
resp = append(resp, mapTitle(t))
}
return GetTitle200JSONResponse(resp), nil
}
func (s Server) GetTitleTitleId(ctx context.Context, req GetTitleTitleIdRequestObject) (GetTitleTitleIdResponseObject, error) {
titleID, err := parseInt32(req.TitleId)
if err != nil {
return GetTitleTitleId404Response{}, nil
}
title, err := s.db.GetTitleByID(ctx, titleID)
if err != nil {
if err == pgx.ErrNoRows {
return GetTitleTitleId404Response{}, nil
}
return nil, err
}
return GetTitleTitleId200JSONResponse(mapTitle(title)), nil
}
func (s Server) PatchTitleTitleId(ctx context.Context, req PatchTitleTitleIdRequestObject) (PatchTitleTitleIdResponseObject, error) {
titleID, err := parseInt32(req.TitleId)
if err != nil {
return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr("invalid title_id")}, nil
}
if req.Body == nil {
return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil
}
body := *req.Body
args := db.UpdateTitleParams{
TitleID: titleID,
}
if v, ok := body["title_names"].(map[string]interface{}); ok {
data, _ := json.Marshal(v)
args.TitleNames = data
// args.TitleNamesValid = true
}
if v, ok := body["studio_id"].(float64); ok {
args.StudioID = pgtype.Int4{Int32: int32(v), Valid: true}
// args.StudioIDValid = true
}
if v, ok := body["poster_id"].(float64); ok {
args.PosterID = pgtype.Int4{Int32: int32(v), Valid: true}
// args.PosterIDValid = true
}
// if v, ok := body["title_status"].(string); ok {
// args.TitleStatus = db.NullTitleStatusT(v)
// // args.TitleStatusValid = true
// }
if v, ok := body["release_year"].(float64); ok {
args.ReleaseYear = pgtype.Int4{Int32: int32(v), Valid: true}
// args.ReleaseYearValid = true
}
if v, ok := body["episodes_aired"].(float64); ok {
args.EpisodesAired = pgtype.Int4{Int32: int32(v), Valid: true}
// args.EpisodesAiredValid = true
}
if v, ok := body["episodes_all"].(float64); ok {
args.EpisodesAll = pgtype.Int4{Int32: int32(v), Valid: true}
// args.EpisodesAllValid = true
}
_, err = s.db.UpdateTitle(ctx, args)
if err != nil {
return PatchTitleTitleId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil
}
return PatchTitleTitleId200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) GetTitleTitleIdReviews(ctx context.Context, req GetTitleTitleIdReviewsRequestObject) (GetTitleTitleIdReviewsResponseObject, error) {
titleID, err := parseInt32(req.TitleId)
if err != nil {
return GetTitleTitleIdReviews200JSONResponse{}, nil
}
limit := int32(20)
offset := int32(0)
if req.Params.Limit != nil {
limit = int32(*req.Params.Limit)
}
if req.Params.Offset != nil {
offset = int32(*req.Params.Offset)
}
reviews, err := s.db.ListReviewsByTitle(ctx, db.ListReviewsByTitleParams{
TitleID: titleID,
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, err
}
var resp []Review
for _, r := range reviews {
resp = append(resp, mapReview(r))
}
return GetTitleTitleIdReviews200JSONResponse(resp), nil
}
func (s Server) PostReviews(ctx context.Context, req PostReviewsRequestObject) (PostReviewsResponseObject, error) {
if req.Body == nil {
return PostReviews200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil
}
body := *req.Body
userID, ok1 := body["user_id"].(float64)
titleID, ok2 := body["title_id"].(float64)
reviewText, ok3 := body["review_text"].(string)
if !ok1 || !ok2 || !ok3 {
return PostReviews200JSONResponse{Success: ptr(false), Error: ptr("user_id, title_id, review_text required")}, nil
}
var imageIDs []int32
if ids, ok := body["image_ids"].([]interface{}); ok {
for _, id := range ids {
if f, ok := id.(float64); ok {
imageIDs = append(imageIDs, int32(f))
}
}
}
_, err := s.db.CreateReview(ctx, db.CreateReviewParams{
UserID: int32(userID),
TitleID: int32(titleID),
ImageIds: imageIDs,
ReviewText: reviewText,
CreationDate: pgtype.Timestamp{Time: time.Now(), Valid: true},
})
if err != nil {
return PostReviews200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil
}
return PostReviews200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) DeleteReviewsReviewId(ctx context.Context, req DeleteReviewsReviewIdRequestObject) (DeleteReviewsReviewIdResponseObject, error) {
reviewID, err := parseInt32(req.ReviewId)
if err != nil {
return DeleteReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("invalid review_id")}, nil
}
err = s.db.DeleteReview(ctx, reviewID)
if err != nil {
if err == pgx.ErrNoRows {
return DeleteReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("review not found")}, nil
}
return nil, err
}
return DeleteReviewsReviewId200JSONResponse{Success: ptr(true)}, nil
}
func (s Server) PatchReviewsReviewId(ctx context.Context, req PatchReviewsReviewIdRequestObject) (PatchReviewsReviewIdResponseObject, error) {
reviewID, err := parseInt32(req.ReviewId)
if err != nil {
return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("invalid review_id")}, nil
}
if req.Body == nil {
return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr("empty body")}, nil
}
body := *req.Body
args := db.UpdateReviewParams{
ReviewID: reviewID,
}
if v, ok := body["review_text"].(string); ok {
args.ReviewText = pgtype.Text{String: v, Valid: true}
// args.ReviewTextValid = true
}
if ids, ok := body["image_ids"].([]interface{}); ok {
var imageIDs []int32
for _, id := range ids {
if f, ok := id.(float64); ok {
imageIDs = append(imageIDs, int32(f))
}
}
args.ImageIds = imageIDs
// args.ImageIdsValid = true
}
_, err = s.db.UpdateReview(ctx, args)
if err != nil {
return PatchReviewsReviewId200JSONResponse{Success: ptr(false), Error: ptr(err.Error())}, nil
}
return PatchReviewsReviewId200JSONResponse{Success: ptr(true)}, nil
}
// —————————————————————————————————————————————
// МАППИНГИ
// —————————————————————————————————————————————
func mapUser(u db.Users) User {
return User{
"user_id": u.UserID,
"avatar_id": pgInt4ToPtr(u.AvatarID),
"nickname": u.Nickname,
"disp_name": pgTextToPtr(u.DispName),
"user_desc": pgTextToPtr(u.UserDesc),
"creation_date": u.CreationDate.Time,
// mail и passhash НЕ возвращаем!
}
}
func mapTitle(t db.Titles) Title {
var releaseSeason interface{}
if t.ReleaseSeason.Valid {
releaseSeason = string(t.ReleaseSeason.ReleaseSeasonT)
}
return Title{
"title_id": t.TitleID,
"title_names": jsonbToInterface(t.TitleNames),
"studio_id": t.StudioID,
"poster_id": pgInt4ToPtr(t.PosterID),
"signal_ids": t.SignalIds,
"title_status": string(t.TitleStatus),
"rating": pgFloat8ToPtr(t.Rating),
"rating_count": pgInt4ToPtr(t.RatingCount),
"release_year": pgInt4ToPtr(t.ReleaseYear),
"release_season": releaseSeason,
"season": pgInt4ToPtr(t.Season),
"episodes_aired": pgInt4ToPtr(t.EpisodesAired),
"episodes_all": pgInt4ToPtr(t.EpisodesAll),
"episodes_len": jsonbToInterface(t.EpisodesLen),
}
}
func mapReview(r db.Reviews) Review {
return Review{
"review_id": r.ReviewID,
"user_id": r.UserID,
"title_id": r.TitleID,
"image_ids": r.ImageIds,
"review_text": r.ReviewText,
"creation_date": r.CreationDate.Time,
}
}
func mapUserTitle(ut db.Usertitles) UserTitle {
return UserTitle{
"usertitle_id": ut.UsertitleID,
"user_id": ut.UserID,
"title_id": ut.TitleID,
"status": string(ut.Status),
"rate": pgInt4ToPtr(ut.Rate),
"review_id": pgInt4ToPtr(ut.ReviewID),
}
}

View file

@ -1,51 +0,0 @@
package handlers
import (
"context"
oapi "nyanimedb/api"
sqlc "nyanimedb/sql"
"strconv"
"github.com/jackc/pgx/v5"
"github.com/oapi-codegen/runtime/types"
)
type Server struct {
db *sqlc.Queries
}
func NewServer(db *sqlc.Queries) Server {
return Server{db: db}
}
func parseInt64(s string) (int32, error) {
i, err := strconv.ParseInt(s, 10, 64)
return int32(i), err
}
func mapUser(u sqlc.GetUserByIDRow) oapi.User {
return oapi.User{
AvatarId: u.AvatarID,
CreationDate: u.CreationDate,
DispName: u.DispName,
Id: &u.ID,
Mail: (*types.Email)(u.Mail),
Nickname: u.Nickname,
UserDesc: u.UserDesc,
}
}
func (s Server) GetUsersUserId(ctx context.Context, req oapi.GetUsersUserIdRequestObject) (oapi.GetUsersUserIdResponseObject, error) {
userID, err := parseInt64(req.UserId)
if err != nil {
return oapi.GetUsersUserId404Response{}, nil
}
user, err := s.db.GetUserByID(context.TODO(), int64(userID))
if err != nil {
if err == pgx.ErrNoRows {
return oapi.GetUsersUserId404Response{}, nil
}
return nil, err
}
return oapi.GetUsersUserId200JSONResponse(mapUser(user)), nil
}

View file

@ -9,7 +9,6 @@ import (
"time" "time"
oapi "nyanimedb/api" oapi "nyanimedb/api"
handlers "nyanimedb/modules/backend/handlers"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -43,7 +42,7 @@ func main() {
queries := sqlc.New(conn) queries := sqlc.New(conn)
server := handlers.NewServer(queries) server := NewServer(queries)
// r.LoadHTMLGlob("templates/*") // r.LoadHTMLGlob("templates/*")
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{

View file

@ -1,17 +1,17 @@
-- name: GetImageByID :one -- name: GetImageByID :one
SELECT id, storage_type, image_path SELECT image_id, storage_type, image_path
FROM images FROM images
WHERE id = $1; WHERE image_id = $1;
-- name: CreateImage :one -- -- name: CreateImage :one
INSERT INTO images (storage_type, image_path) -- INSERT INTO images (storage_type, image_path)
VALUES ($1, $2) -- VALUES ($1, $2)
RETURNING id, storage_type, image_path; -- RETURNING image_id, storage_type, image_path;
-- name: GetUserByID :one -- -- name: GetUserByID :one
SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date
FROM users -- FROM users
WHERE id = $1; -- WHERE user_id = $1;
-- -- name: ListUsers :many -- -- name: ListUsers :many
-- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date

View file

@ -10,8 +10,7 @@
"dependencies": { "dependencies": {
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1"
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
@ -2062,15 +2061,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3356,7 +3346,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -3374,44 +3363,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
"integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz",
"integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -3515,12 +3466,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -12,8 +12,7 @@
"dependencies": { "dependencies": {
"axios": "^1.12.2", "axios": "^1.12.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1"
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View file

@ -1,15 +1,8 @@
import React from "react"; import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import UserPage from "./components/UserPage/UserPage"; import UserPage from "./components/UserPage/UserPage";
const App: React.FC = () => { const App: React.FC = () => {
return ( return <UserPage />;
<Router>
<Routes>
<Route path="/users/:id" element={<UserPage />} />
</Routes>
</Router>
);
}; };
export default App; export default App;

View file

@ -1,21 +1,17 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom"; // <-- import import { DefaultService } from "../../api/services/DefaultService"; // adjust path
import { DefaultService } from "../../api/services/DefaultService";
import type { User } from "../../api/models/User"; import type { User } from "../../api/models/User";
import styles from "./UserPage.module.css"; import styles from "./UserPage.module.css";
const UserPage: React.FC = () => { const UserPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); // <-- get user id from URL
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return;
const getUserInfo = async () => { const getUserInfo = async () => {
try { try {
const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id const userInfo = await DefaultService.getUsers("1", "all");
setUser(userInfo); setUser(userInfo);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -25,7 +21,7 @@ const UserPage: React.FC = () => {
} }
}; };
getUserInfo(); getUserInfo();
}, [id]); }, []);
if (loading) return <div className={styles.loader}>Loading...</div>; if (loading) return <div className={styles.loader}>Loading...</div>;
if (error) return <div className={styles.error}>{error}</div>; if (error) return <div className={styles.error}>{error}</div>;

View file

@ -1,12 +1,7 @@
DROP TRIGGER IF EXISTS trg_update_title_rating ON usertitles;
DROP TRIGGER IF EXISTS trg_notify_new_signal ON signals;
DROP FUNCTION IF EXISTS update_title_rating();
DROP FUNCTION IF EXISTS notify_new_signal();
DROP TABLE IF EXISTS signals; DROP TABLE IF EXISTS signals;
DROP TABLE IF EXISTS title_tags; DROP TABLE IF EXISTS title_tags;
DROP TABLE IF EXISTS usertitles; DROP TABLE IF EXISTS usertitles;
DROP TABLE IF EXISTS reviews;
DROP TABLE IF EXISTS titles; DROP TABLE IF EXISTS titles;
DROP TABLE IF EXISTS studios; DROP TABLE IF EXISTS studios;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;

View file

@ -1,141 +1,99 @@
-- TODO: -- TODO:
-- title table triggers
-- maybe jsonb constraints -- maybe jsonb constraints
-- clean unused images -- actions (delete)
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');
CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall');
CREATE TABLE providers ( CREATE TABLE providers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, provider_id serial PRIMARY KEY,
provider_name text NOT NULL, provider_name varchar(64) NOT NULL
credentials jsonb -- token
); );
CREATE TABLE tags ( CREATE TABLE tags (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tag_id serial PRIMARY KEY,
tag_names jsonb NOT NULL tag_names jsonb NOT NULL --mb constraints
); );
-- clean unused images
CREATE TABLE images ( CREATE TABLE images (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, image_id serial PRIMARY KEY,
storage_type storage_type_t NOT NULL, storage_type storage_type_t NOT NULL,
image_path text UNIQUE NOT NULL image_path varchar(256) UNIQUE NOT NULL
); );
CREATE TABLE users ( CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id serial PRIMARY KEY,
avatar_id bigint REFERENCES images (id), avatar_id int REFERENCES images (image_id),
passhash text NOT NULL, passhash text NOT NULL,
mail text CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), mail varchar(64) CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'),
nickname text NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'),
disp_name text, disp_name varchar(32),
user_desc text, user_desc varchar(512),
creation_date timestamptz NOT NULL, -- timestamp tl dr, also add access ts
last_login timestamptz creation_date timestamp NOT NULL
); );
CREATE TABLE studios ( CREATE TABLE studios (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, studio_id serial PRIMARY KEY,
studio_name text UNIQUE, studio_name varchar(64) UNIQUE,
illust_id bigint REFERENCES images (id), illust_id int REFERENCES images (image_id),
studio_desc text studio_desc text
); );
CREATE TABLE titles ( CREATE TABLE titles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title_id serial PRIMARY KEY,
title_names jsonb NOT NULL, title_names jsonb NOT NULL,
studio_id bigint NOT NULL REFERENCES studios (id), studio_id int NOT NULL REFERENCES studios,
poster_id bigint REFERENCES images (id), poster_id int REFERENCES images (image_id),
title_status title_status_t NOT NULL, --signal_ids int[] NOT NULL,
rating float CHECK (rating >= 0 AND rating <= 10), title_status title_status_t NOT NULL,
rating_count int CHECK (rating_count >= 0), rating float CHECK (rating > 0 AND rating <= 10), --by trigger
release_year int CHECK (release_year >= 1900), rating_count int CHECK (rating_count >= 0), --by trigger
release_season release_season_t, release_year int CHECK (release_year >= 1900),
season int CHECK (season >= 0), release_season release_season_t,
episodes_aired int CHECK (episodes_aired >= 0), season int CHECK (season >= 0),
episodes_all int CHECK (episodes_all >= 0), episodes_aired int CHECK (episodes_aired >= 0),
episodes_len jsonb, episodes_all int CHECK (episodes_all >= 0),
episodes_len jsonb,
CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) CHECK ((episodes_aired IS NULL AND episodes_all IS NULL)
OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL
AND episodes_aired <= episodes_all)) AND episodes_aired <= episodes_all))
); );
CREATE TABLE reviews (
review_id serial PRIMARY KEY, --???
user_id int NOT NULL REFERENCES users,
title_id int NOT NULL REFERENCES titles,
--image_ids int[], move somewhere
review_text text NOT NULL,
creation_date timestamp NOT NULL
-- constrai (title, user)
);
CREATE TABLE usertitles ( CREATE TABLE usertitles (
PRIMARY KEY (user_id, title_id), usertitle_id serial PRIMARY KEY, -- bigserial, replace by (,)
user_id bigint NOT NULL REFERENCES users (id), user_id int NOT NULL REFERENCES users,
title_id bigint NOT NULL REFERENCES titles (id), title_id int NOT NULL REFERENCES titles,
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_text text, review_id int REFERENCES reviews
review_date timestamptz
); );
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 int NOT NULL REFERENCES titles,
tag_id bigint NOT NULL REFERENCES tags (id) tag_id int NOT NULL REFERENCES tags
); );
CREATE TABLE signals ( CREATE TABLE signals (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, signal_id serial PRIMARY KEY,
title_id bigint REFERENCES titles (id), -- title_id
raw_data jsonb NOT NULL, raw_data jsonb NOT NULL,
provider_id bigint NOT NULL REFERENCES providers (id), provider_id int NOT NULL REFERENCES providers,
pending boolean NOT NULL dirty bool NOT NULL
); );
-- Functions
CREATE OR REPLACE FUNCTION update_title_rating()
RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'INSERT') OR (TG_OP = 'UPDATE' AND NEW.rate IS DISTINCT FROM OLD.rate) THEN
UPDATE titles
SET
rating = sub.avg_rating,
rating_count = sub.rating_count
FROM (
SELECT
title_id,
AVG(rate)::float AS avg_rating,
COUNT(rate) AS rating_count
FROM usertitles
WHERE title_id = NEW.title_id AND rate IS NOT NULL
GROUP BY title_id
) AS sub
WHERE titles.id = sub.title_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_new_signal()
RETURNS TRIGGER AS $$
DECLARE
payload JSON;
BEGIN
payload := json_build_object(
'signal_id', NEW.id,
'title_id', NEW.title_id,
'provider_id', NEW.provider_id,
'pending', NEW.pending,
'timestamp', NOW()
);
PERFORM pg_notify('new_signal', payload::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers
CREATE TRIGGER trg_update_title_rating
AFTER INSERT OR UPDATE OF rate ON usertitles
FOR EACH ROW
EXECUTE FUNCTION update_title_rating();
CREATE TRIGGER trg_notify_new_signal
AFTER INSERT ON signals
FOR EACH ROW
EXECUTE FUNCTION notify_new_signal();

View file

@ -7,7 +7,6 @@ package sqlc
import ( import (
"database/sql/driver" "database/sql/driver"
"fmt" "fmt"
"time"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@ -186,42 +185,48 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) {
} }
type Image struct { type Image struct {
ID int64 `json:"id"` ImageID int32 `json:"image_id"`
StorageType StorageTypeT `json:"storage_type"` StorageType StorageTypeT `json:"storage_type"`
ImagePath string `json:"image_path"` ImagePath string `json:"image_path"`
} }
type Provider struct { type Provider struct {
ID int64 `json:"id"` ProviderID int32 `json:"provider_id"`
ProviderName string `json:"provider_name"` ProviderName string `json:"provider_name"`
Credentials []byte `json:"credentials"` }
type Review struct {
ReviewID int32 `json:"review_id"`
UserID int32 `json:"user_id"`
TitleID int32 `json:"title_id"`
ReviewText string `json:"review_text"`
CreationDate pgtype.Timestamp `json:"creation_date"`
} }
type Signal struct { type Signal struct {
ID int64 `json:"id"` SignalID int32 `json:"signal_id"`
TitleID *int64 `json:"title_id"`
RawData []byte `json:"raw_data"` RawData []byte `json:"raw_data"`
ProviderID int64 `json:"provider_id"` ProviderID int32 `json:"provider_id"`
Pending bool `json:"pending"` Dirty bool `json:"dirty"`
} }
type Studio struct { type Studio struct {
ID int64 `json:"id"` StudioID int32 `json:"studio_id"`
StudioName *string `json:"studio_name"` StudioName *string `json:"studio_name"`
IllustID *int64 `json:"illust_id"` IllustID *int32 `json:"illust_id"`
StudioDesc *string `json:"studio_desc"` StudioDesc *string `json:"studio_desc"`
} }
type Tag struct { type Tag struct {
ID int64 `json:"id"` TagID int32 `json:"tag_id"`
TagNames []byte `json:"tag_names"` TagNames []byte `json:"tag_names"`
} }
type Title struct { type Title struct {
ID int64 `json:"id"` TitleID int32 `json:"title_id"`
TitleNames []byte `json:"title_names"` TitleNames []byte `json:"title_names"`
StudioID int64 `json:"studio_id"` StudioID int32 `json:"studio_id"`
PosterID *int64 `json:"poster_id"` PosterID *int32 `json:"poster_id"`
TitleStatus TitleStatusT `json:"title_status"` TitleStatus TitleStatusT `json:"title_status"`
Rating *float64 `json:"rating"` Rating *float64 `json:"rating"`
RatingCount *int32 `json:"rating_count"` RatingCount *int32 `json:"rating_count"`
@ -234,27 +239,26 @@ type Title struct {
} }
type TitleTag struct { type TitleTag struct {
TitleID int64 `json:"title_id"` TitleID int32 `json:"title_id"`
TagID int64 `json:"tag_id"` TagID int32 `json:"tag_id"`
} }
type User struct { type User struct {
ID int64 `json:"id"` UserID int32 `json:"user_id"`
AvatarID *int64 `json:"avatar_id"` AvatarID *int32 `json:"avatar_id"`
Passhash string `json:"passhash"` Passhash string `json:"passhash"`
Mail *string `json:"mail"` Mail *string `json:"mail"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
DispName *string `json:"disp_name"` DispName *string `json:"disp_name"`
UserDesc *string `json:"user_desc"` UserDesc *string `json:"user_desc"`
CreationDate time.Time `json:"creation_date"` CreationDate pgtype.Timestamp `json:"creation_date"`
LastLogin pgtype.Timestamptz `json:"last_login"`
} }
type Usertitle struct { type Usertitle struct {
UserID int64 `json:"user_id"` UsertitleID int32 `json:"usertitle_id"`
TitleID int64 `json:"title_id"` UserID int32 `json:"user_id"`
Status UsertitleStatusT `json:"status"` TitleID int32 `json:"title_id"`
Rate *int32 `json:"rate"` Status UsertitleStatusT `json:"status"`
ReviewText *string `json:"review_text"` Rate *int32 `json:"rate"`
ReviewDate pgtype.Timestamptz `json:"review_date"` ReviewID *int32 `json:"review_id"`
} }

View file

@ -7,67 +7,17 @@ package sqlc
import ( import (
"context" "context"
"time"
) )
const createImage = `-- name: CreateImage :one
INSERT INTO images (storage_type, image_path)
VALUES ($1, $2)
RETURNING id, storage_type, image_path
`
type CreateImageParams struct {
StorageType StorageTypeT `json:"storage_type"`
ImagePath string `json:"image_path"`
}
func (q *Queries) CreateImage(ctx context.Context, arg CreateImageParams) (Image, error) {
row := q.db.QueryRow(ctx, createImage, arg.StorageType, arg.ImagePath)
var i Image
err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath)
return i, err
}
const getImageByID = `-- name: GetImageByID :one const getImageByID = `-- name: GetImageByID :one
SELECT id, storage_type, image_path SELECT image_id, storage_type, image_path
FROM images FROM images
WHERE id = $1 WHERE image_id = $1
` `
func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Image, error) {
row := q.db.QueryRow(ctx, getImageByID, id) row := q.db.QueryRow(ctx, getImageByID, imageID)
var i Image var i Image
err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date
FROM users
WHERE id = $1
`
type GetUserByIDRow struct {
ID int64 `json:"id"`
AvatarID *int64 `json:"avatar_id"`
Mail *string `json:"mail"`
Nickname string `json:"nickname"`
DispName *string `json:"disp_name"`
UserDesc *string `json:"user_desc"`
CreationDate time.Time `json:"creation_date"`
}
func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.AvatarID,
&i.Mail,
&i.Nickname,
&i.DispName,
&i.UserDesc,
&i.CreationDate,
)
return i, err return i, err
} }

View file

@ -11,17 +11,4 @@ sql:
sql_package: "pgx/v5" sql_package: "pgx/v5"
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
overrides:
- db_type: "uuid"
nullable: false
go_type:
import: "github.com/gofrs/uuid"
package: "gofrsuuid"
type: UUID
pointer: true
- db_type: "timestamptz"
nullable: false
go_type:
import: "time"
type: "Time"