diff --git a/api/api.gen.go b/api/api.gen.go index 58b5b53..24aebd3 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -8,28 +8,48 @@ import ( "encoding/json" "fmt" "net/http" + "time" "github.com/gin-gonic/gin" "github.com/oapi-codegen/runtime" strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin" + openapi_types "github.com/oapi-codegen/runtime/types" ) -// Title defines model for Title. -type Title map[string]interface{} +// User defines model for User. +type User struct { + // AvatarId ID of the user avatar (references images table) + AvatarId *int64 `json:"avatar_id"` -// GetTitleParams defines parameters for GetTitle. -type GetTitleParams struct { - Query *string `form:"query,omitempty" json:"query,omitempty"` - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + // CreationDate Timestamp when the user was created + CreationDate time.Time `json:"creation_date"` + + // DispName Display name + DispName *string `json:"disp_name,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"` } // ServerInterface represents all server handlers. type ServerInterface interface { - // Get titles - // (GET /title) - GetTitle(c *gin.Context, params GetTitleParams) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) } // ServerInterfaceWrapper converts contexts to parameters. @@ -41,37 +61,22 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// GetTitle operation middleware -func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { +// GetUsersUserId operation middleware +func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { var err error + // ------------- Path parameter "user_id" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "user_id", c.Param("user_id"), &userId, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter user_id: %w", err), http.StatusBadRequest) + return + } + // Parameter object where we will unmarshal all parameters from the context - var params GetTitleParams - - // ------------- Optional query parameter "query" ------------- - - err = runtime.BindQueryParameter("form", true, false, "query", c.Request.URL.Query(), ¶ms.Query) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter query: %w", err), http.StatusBadRequest) - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameter("form", true, false, "limit", c.Request.URL.Query(), ¶ms.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(), ¶ms.Offset) - if err != nil { - siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter offset: %w", err), http.StatusBadRequest) - return - } + var params GetUsersUserIdParams // ------------- Optional query parameter "fields" ------------- @@ -88,7 +93,7 @@ func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { } } - siw.Handler.GetTitle(c, params) + siw.Handler.GetUsersUserId(c, userId, params) } // GinServerOptions provides options for the Gin server. @@ -118,39 +123,40 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.GET(options.BaseURL+"/title", wrapper.GetTitle) + router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) } -type GetTitleRequestObject struct { - Params GetTitleParams +type GetUsersUserIdRequestObject struct { + UserId string `json:"user_id"` + Params GetUsersUserIdParams } -type GetTitleResponseObject interface { - VisitGetTitleResponse(w http.ResponseWriter) error +type GetUsersUserIdResponseObject interface { + VisitGetUsersUserIdResponse(w http.ResponseWriter) error } -type GetTitle200JSONResponse []Title +type GetUsersUserId200JSONResponse User -func (response GetTitle200JSONResponse) VisitGetTitleResponse(w http.ResponseWriter) error { +func (response GetUsersUserId200JSONResponse) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetTitle204Response struct { +type GetUsersUserId404Response struct { } -func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { - w.WriteHeader(204) +func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { + w.WriteHeader(404) return nil } // StrictServerInterface represents all server handlers. type StrictServerInterface interface { - // Get titles - // (GET /title) - GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) + // Get user info + // (GET /users/{user_id}) + GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -165,17 +171,18 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// GetTitle operation middleware -func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { - var request GetTitleRequestObject +// GetUsersUserId operation middleware +func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { + var request GetUsersUserIdRequestObject + request.UserId = userId request.Params = params handler := func(ctx *gin.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetTitle(ctx, request.(GetTitleRequestObject)) + return sh.ssi.GetUsersUserId(ctx, request.(GetUsersUserIdRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetTitle") + handler = middleware(handler, "GetUsersUserId") } response, err := handler(ctx, request) @@ -183,8 +190,8 @@ func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetTitleResponseObject); ok { - if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { + if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index b2a2df0..97fa3a4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5,40 +5,40 @@ info: servers: - url: https://api.example.com paths: - /title: - get: - summary: Get titles - parameters: - - in: query - name: query - schema: - type: string - - in: query - name: limit - schema: - type: integer - default: 10 - - in: query - name: offset - schema: - type: integer - default: 0 - - in: query - name: fields - schema: - type: string - default: all - responses: - '200': - description: List of titles - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Title' - '204': - description: No titles found + # /title: + # get: + # summary: Get titles + # parameters: + # - in: query + # name: query + # schema: + # type: string + # - in: query + # name: limit + # schema: + # type: integer + # default: 10 + # - in: query + # name: offset + # schema: + # type: integer + # default: 0 + # - in: query + # name: fields + # schema: + # type: string + # default: all + # responses: + # '200': + # description: List of titles + # content: + # application/json: + # schema: + # type: array + # items: + # $ref: '#/components/schemas/Title' + # '204': + # description: No titles found # /title/{title_id}: # get: @@ -124,122 +124,122 @@ paths: # '204': # description: No reviews found -# /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' -# '404': -# description: User not found + /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' + '404': + description: User not found -# patch: -# summary: Update user -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/User' -# responses: -# '200': -# description: Update result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string + # patch: + # summary: Update user + # parameters: + # - in: path + # name: user_id + # required: true + # schema: + # type: string + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Update result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string -# delete: -# summary: Delete user -# parameters: -# - in: path -# name: user_id -# required: true -# schema: -# type: string -# responses: -# '200': -# description: Delete result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string + # delete: + # summary: Delete user + # parameters: + # - in: path + # name: user_id + # required: true + # schema: + # type: string + # responses: + # '200': + # description: Delete result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string -# /users: -# get: -# summary: Search user -# parameters: -# - in: query -# name: query -# schema: -# type: string -# - in: query -# name: fields -# schema: -# type: string -# responses: -# '200': -# description: List of users -# content: -# application/json: -# schema: -# type: array -# items: -# $ref: '#/components/schemas/User' + # /users: + # get: + # summary: Search user + # parameters: + # - in: query + # name: query + # schema: + # type: string + # - in: query + # name: fields + # schema: + # type: string + # responses: + # '200': + # description: List of users + # content: + # application/json: + # schema: + # type: array + # items: + # $ref: '#/components/schemas/User' -# post: -# summary: Add new user -# requestBody: -# required: true -# content: -# application/json: -# schema: -# $ref: '#/components/schemas/User' -# responses: -# '200': -# description: Add result -# content: -# application/json: -# schema: -# type: object -# properties: -# success: -# type: boolean -# error: -# type: string -# user_json: -# $ref: '#/components/schemas/User' + # post: + # summary: Add new user + # requestBody: + # required: true + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/User' + # responses: + # '200': + # description: Add result + # content: + # application/json: + # schema: + # type: object + # properties: + # success: + # type: boolean + # error: + # type: string + # user_json: + # $ref: '#/components/schemas/User' # /users/{user_id}/titles: # get: @@ -541,14 +541,14 @@ components: User: type: object properties: - user_id: + id: type: integer - format: int32 + format: int64 description: Unique user ID (primary key) example: 1 avatar_id: type: integer - format: int32 + format: int64 description: ID of the user avatar (references images table) nullable: true example: null diff --git a/modules/backend/handlers.go b/modules/backend/handlers.go deleted file mode 100644 index 366f298..0000000 --- a/modules/backend/handlers.go +++ /dev/null @@ -1,719 +0,0 @@ -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), - } -} diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go new file mode 100644 index 0000000..b67153d --- /dev/null +++ b/modules/backend/handlers/users.go @@ -0,0 +1,51 @@ +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 +} diff --git a/modules/backend/main.go b/modules/backend/main.go index a9e8db0..42a66d3 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -9,6 +9,7 @@ import ( "time" oapi "nyanimedb/api" + handlers "nyanimedb/modules/backend/handlers" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -42,7 +43,7 @@ func main() { queries := sqlc.New(conn) - server := NewServer(queries) + server := handlers.NewServer(queries) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7c9c197..b1dd8af 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,17 +1,17 @@ -- name: GetImageByID :one -SELECT image_id, storage_type, image_path +SELECT id, storage_type, image_path FROM images -WHERE image_id = $1; +WHERE id = $1; --- -- name: CreateImage :one --- INSERT INTO images (storage_type, image_path) --- VALUES ($1, $2) --- RETURNING image_id, storage_type, image_path; +-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING id, storage_type, image_path; --- -- name: GetUserByID :one --- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date --- FROM users --- WHERE user_id = $1; +-- name: GetUserByID :one +SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date +FROM users +WHERE id = $1; -- -- name: ListUsers :many -- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date diff --git a/modules/frontend/package-lock.json b/modules/frontend/package-lock.json index b1789ed..6a06afb 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.4" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -2061,6 +2062,15 @@ "dev": true, "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3346,6 +3356,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3363,6 +3374,44 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3466,6 +3515,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/modules/frontend/package.json b/modules/frontend/package.json index c40ff17..b4977aa 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -12,7 +12,8 @@ "dependencies": { "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.4" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index 6b2ee5f..a88ad57 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,8 +1,15 @@ import React from "react"; +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import UserPage from "./components/UserPage/UserPage"; const App: React.FC = () => { - return ; + return ( + + + } /> + + + ); }; -export default App; +export default App; \ No newline at end of file diff --git a/modules/frontend/src/components/UserPage/UserPage.tsx b/modules/frontend/src/components/UserPage/UserPage.tsx index b0db90c..0a83679 100644 --- a/modules/frontend/src/components/UserPage/UserPage.tsx +++ b/modules/frontend/src/components/UserPage/UserPage.tsx @@ -1,17 +1,21 @@ import React, { useEffect, useState } from "react"; -import { DefaultService } from "../../api/services/DefaultService"; // adjust path +import { useParams } from "react-router-dom"; // <-- import +import { DefaultService } from "../../api/services/DefaultService"; import type { User } from "../../api/models/User"; import styles from "./UserPage.module.css"; const UserPage: React.FC = () => { + const { id } = useParams<{ id: string }>(); // <-- get user id from URL const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + if (!id) return; + const getUserInfo = async () => { try { - const userInfo = await DefaultService.getUsers("1", "all"); + const userInfo = await DefaultService.getUsers(id, "all"); // <-- use dynamic id setUser(userInfo); } catch (err) { console.error(err); @@ -21,7 +25,7 @@ const UserPage: React.FC = () => { } }; getUserInfo(); - }, []); + }, [id]); if (loading) return
Loading...
; if (error) return
{error}
; diff --git a/sql/migrations/000001_init.down.sql b/sql/migrations/000001_init.down.sql index d9f23c0..dc52d23 100644 --- a/sql/migrations/000001_init.down.sql +++ b/sql/migrations/000001_init.down.sql @@ -1,7 +1,12 @@ +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 title_tags; DROP TABLE IF EXISTS usertitles; -DROP TABLE IF EXISTS reviews; DROP TABLE IF EXISTS titles; DROP TABLE IF EXISTS studios; DROP TABLE IF EXISTS users; diff --git a/sql/migrations/000001_init.up.sql b/sql/migrations/000001_init.up.sql index 93ce071..0b7fa33 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,99 +1,141 @@ -- TODO: --- title table triggers -- maybe jsonb constraints --- actions (delete) +-- clean unused images CREATE TYPE usertitle_status_t AS ENUM ('finished', 'planned', 'dropped', 'in-progress'); CREATE TYPE storage_type_t AS ENUM ('local', 's3'); CREATE TYPE title_status_t AS ENUM ('finished', 'ongoing', 'planned'); CREATE TYPE release_season_t AS ENUM ('winter', 'spring', 'summer', 'fall'); CREATE TABLE providers ( - provider_id serial PRIMARY KEY, - provider_name varchar(64) NOT NULL - -- token + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + provider_name text NOT NULL, + credentials jsonb ); CREATE TABLE tags ( - tag_id serial PRIMARY KEY, - tag_names jsonb NOT NULL --mb constraints + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tag_names jsonb NOT NULL ); - --- clean unused images CREATE TABLE images ( - image_id serial PRIMARY KEY, - storage_type storage_type_t NOT NULL, - image_path varchar(256) UNIQUE NOT NULL + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + storage_type storage_type_t NOT NULL, + image_path text UNIQUE NOT NULL ); CREATE TABLE users ( - user_id serial PRIMARY KEY, - avatar_id int REFERENCES images (image_id), - passhash text NOT NULL, - mail varchar(64) CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), - nickname varchar(16) NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), - disp_name varchar(32), - user_desc varchar(512), - -- timestamp tl dr, also add access ts - creation_date timestamp NOT NULL + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + avatar_id bigint REFERENCES images (id), + passhash text NOT NULL, + mail text CHECK (mail ~ '[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+'), + nickname text NOT NULL CHECK (nickname ~ '^[a-zA-Z0-9_-]+$'), + disp_name text, + user_desc text, + creation_date timestamptz NOT NULL, + last_login timestamptz ); CREATE TABLE studios ( - studio_id serial PRIMARY KEY, - studio_name varchar(64) UNIQUE, - illust_id int REFERENCES images (image_id), - studio_desc text + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + studio_name text UNIQUE, + illust_id bigint REFERENCES images (id), + studio_desc text ); CREATE TABLE titles ( - title_id serial PRIMARY KEY, - title_names jsonb NOT NULL, - studio_id int NOT NULL REFERENCES studios, - poster_id int REFERENCES images (image_id), - --signal_ids int[] NOT NULL, - title_status title_status_t NOT NULL, - rating float CHECK (rating > 0 AND rating <= 10), --by trigger - rating_count int CHECK (rating_count >= 0), --by trigger - release_year int CHECK (release_year >= 1900), - release_season release_season_t, - season int CHECK (season >= 0), - episodes_aired int CHECK (episodes_aired >= 0), - episodes_all int CHECK (episodes_all >= 0), - episodes_len jsonb, + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title_names jsonb NOT NULL, + studio_id bigint NOT NULL REFERENCES studios (id), + poster_id bigint REFERENCES images (id), + title_status title_status_t NOT NULL, + rating float CHECK (rating >= 0 AND rating <= 10), + rating_count int CHECK (rating_count >= 0), + release_year int CHECK (release_year >= 1900), + release_season release_season_t, + season int CHECK (season >= 0), + episodes_aired int CHECK (episodes_aired >= 0), + episodes_all int CHECK (episodes_all >= 0), + episodes_len jsonb, CHECK ((episodes_aired IS NULL AND episodes_all IS NULL) OR (episodes_aired IS NOT NULL AND episodes_all IS NOT NULL 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 ( - usertitle_id serial PRIMARY KEY, -- bigserial, replace by (,) - user_id int NOT NULL REFERENCES users, - title_id int NOT NULL REFERENCES titles, - status usertitle_status_t NOT NULL, - rate int CHECK (rate > 0 AND rate <= 10), - review_id int REFERENCES reviews + PRIMARY KEY (user_id, title_id), + user_id bigint NOT NULL REFERENCES users (id), + title_id bigint NOT NULL REFERENCES titles (id), + status usertitle_status_t NOT NULL, + rate int CHECK (rate > 0 AND rate <= 10), + review_text text, + review_date timestamptz ); CREATE TABLE title_tags ( - PRIMARY KEY (title_id, tag_id), - title_id int NOT NULL REFERENCES titles, - tag_id int NOT NULL REFERENCES tags + PRIMARY KEY (title_id, tag_id), + title_id bigint NOT NULL REFERENCES titles (id), + tag_id bigint NOT NULL REFERENCES tags (id) ); CREATE TABLE signals ( - signal_id serial PRIMARY KEY, - -- title_id - raw_data jsonb NOT NULL, - provider_id int NOT NULL REFERENCES providers, - dirty bool NOT NULL -); \ No newline at end of file + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title_id bigint REFERENCES titles (id), + raw_data jsonb NOT NULL, + provider_id bigint NOT NULL REFERENCES providers (id), + pending boolean 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(); \ No newline at end of file diff --git a/sql/models.go b/sql/models.go index 4bed173..928d5ac 100644 --- a/sql/models.go +++ b/sql/models.go @@ -7,6 +7,7 @@ package sqlc import ( "database/sql/driver" "fmt" + "time" "github.com/jackc/pgx/v5/pgtype" ) @@ -185,48 +186,42 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { } type Image struct { - ImageID int32 `json:"image_id"` + ID int64 `json:"id"` StorageType StorageTypeT `json:"storage_type"` ImagePath string `json:"image_path"` } type Provider struct { - ProviderID int32 `json:"provider_id"` + ID int64 `json:"id"` ProviderName string `json:"provider_name"` -} - -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"` + Credentials []byte `json:"credentials"` } type Signal struct { - SignalID int32 `json:"signal_id"` + ID int64 `json:"id"` + TitleID *int64 `json:"title_id"` RawData []byte `json:"raw_data"` - ProviderID int32 `json:"provider_id"` - Dirty bool `json:"dirty"` + ProviderID int64 `json:"provider_id"` + Pending bool `json:"pending"` } type Studio struct { - StudioID int32 `json:"studio_id"` + ID int64 `json:"id"` StudioName *string `json:"studio_name"` - IllustID *int32 `json:"illust_id"` + IllustID *int64 `json:"illust_id"` StudioDesc *string `json:"studio_desc"` } type Tag struct { - TagID int32 `json:"tag_id"` + ID int64 `json:"id"` TagNames []byte `json:"tag_names"` } type Title struct { - TitleID int32 `json:"title_id"` + ID int64 `json:"id"` TitleNames []byte `json:"title_names"` - StudioID int32 `json:"studio_id"` - PosterID *int32 `json:"poster_id"` + StudioID int64 `json:"studio_id"` + PosterID *int64 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` Rating *float64 `json:"rating"` RatingCount *int32 `json:"rating_count"` @@ -239,26 +234,27 @@ type Title struct { } type TitleTag struct { - TitleID int32 `json:"title_id"` - TagID int32 `json:"tag_id"` + TitleID int64 `json:"title_id"` + TagID int64 `json:"tag_id"` } type User struct { - UserID int32 `json:"user_id"` - AvatarID *int32 `json:"avatar_id"` - Passhash string `json:"passhash"` - Mail *string `json:"mail"` - Nickname string `json:"nickname"` - DispName *string `json:"disp_name"` - UserDesc *string `json:"user_desc"` - CreationDate pgtype.Timestamp `json:"creation_date"` + ID int64 `json:"id"` + AvatarID *int64 `json:"avatar_id"` + Passhash string `json:"passhash"` + Mail *string `json:"mail"` + Nickname string `json:"nickname"` + DispName *string `json:"disp_name"` + UserDesc *string `json:"user_desc"` + CreationDate time.Time `json:"creation_date"` + LastLogin pgtype.Timestamptz `json:"last_login"` } type Usertitle struct { - UsertitleID int32 `json:"usertitle_id"` - UserID int32 `json:"user_id"` - TitleID int32 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewID *int32 `json:"review_id"` + 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"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 2b00bef..8f92c2a 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,17 +7,67 @@ package sqlc import ( "context" + "time" ) -const getImageByID = `-- name: GetImageByID :one -SELECT image_id, storage_type, image_path -FROM images -WHERE image_id = $1 +const createImage = `-- name: CreateImage :one +INSERT INTO images (storage_type, image_path) +VALUES ($1, $2) +RETURNING id, storage_type, image_path ` -func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Image, error) { - row := q.db.QueryRow(ctx, getImageByID, imageID) +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.ImageID, &i.StorageType, &i.ImagePath) + err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) + return i, err +} + +const getImageByID = `-- name: GetImageByID :one +SELECT id, storage_type, image_path +FROM images +WHERE id = $1 +` + +func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, id) + var i Image + err := row.Scan(&i.ID, &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 } diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index 23b22f9..f44761e 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -11,4 +11,17 @@ sql: sql_package: "pgx/v5" sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: true - emit_pointers_for_null_types: true \ No newline at end of file + 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" \ No newline at end of file