diff --git a/api/api.gen.go b/api/api.gen.go index 24aebd3..58b5b53 100644 --- a/api/api.gen.go +++ b/api/api.gen.go @@ -8,48 +8,28 @@ 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" ) -// User defines model for User. -type User struct { - // AvatarId ID of the user avatar (references images table) - AvatarId *int64 `json:"avatar_id"` +// Title defines model for Title. +type Title map[string]interface{} - // 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 { +// 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"` Fields *string `form:"fields,omitempty" json:"fields,omitempty"` } // ServerInterface represents all server handlers. type ServerInterface interface { - // Get user info - // (GET /users/{user_id}) - GetUsersUserId(c *gin.Context, userId string, params GetUsersUserIdParams) + // Get titles + // (GET /title) + GetTitle(c *gin.Context, params GetTitleParams) } // ServerInterfaceWrapper converts contexts to parameters. @@ -61,22 +41,37 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(c *gin.Context) -// GetUsersUserId operation middleware -func (siw *ServerInterfaceWrapper) GetUsersUserId(c *gin.Context) { +// GetTitle operation middleware +func (siw *ServerInterfaceWrapper) GetTitle(c *gin.Context) { var err error - // ------------- Path parameter "user_id" ------------- - var userId string + // Parameter object where we will unmarshal all parameters from the context + 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(), ¶ms.Query) 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 } - // Parameter object where we will unmarshal all parameters from the context - var params GetUsersUserIdParams + // ------------- 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 + } // ------------- 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. @@ -123,40 +118,39 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options ErrorHandler: errorHandler, } - router.GET(options.BaseURL+"/users/:user_id", wrapper.GetUsersUserId) + router.GET(options.BaseURL+"/title", wrapper.GetTitle) } -type GetUsersUserIdRequestObject struct { - UserId string `json:"user_id"` - Params GetUsersUserIdParams +type GetTitleRequestObject struct { + Params GetTitleParams } -type GetUsersUserIdResponseObject interface { - VisitGetUsersUserIdResponse(w http.ResponseWriter) error +type GetTitleResponseObject interface { + 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.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type GetUsersUserId404Response struct { +type GetTitle204Response struct { } -func (response GetUsersUserId404Response) VisitGetUsersUserIdResponse(w http.ResponseWriter) error { - w.WriteHeader(404) +func (response GetTitle204Response) VisitGetTitleResponse(w http.ResponseWriter) error { + w.WriteHeader(204) return nil } // StrictServerInterface represents all server handlers. type StrictServerInterface interface { - // Get user info - // (GET /users/{user_id}) - GetUsersUserId(ctx context.Context, request GetUsersUserIdRequestObject) (GetUsersUserIdResponseObject, error) + // Get titles + // (GET /title) + GetTitle(ctx context.Context, request GetTitleRequestObject) (GetTitleResponseObject, error) } type StrictHandlerFunc = strictgin.StrictGinHandlerFunc @@ -171,18 +165,17 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } -// GetUsersUserId operation middleware -func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params GetUsersUserIdParams) { - var request GetUsersUserIdRequestObject +// GetTitle operation middleware +func (sh *strictHandler) GetTitle(ctx *gin.Context, params GetTitleParams) { + var request GetTitleRequestObject - request.UserId = userId request.Params = params 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 { - handler = middleware(handler, "GetUsersUserId") + handler = middleware(handler, "GetTitle") } response, err := handler(ctx, request) @@ -190,8 +183,8 @@ func (sh *strictHandler) GetUsersUserId(ctx *gin.Context, userId string, params if err != nil { ctx.Error(err) ctx.Status(http.StatusInternalServerError) - } else if validResponse, ok := response.(GetUsersUserIdResponseObject); ok { - if err := validResponse.VisitGetUsersUserIdResponse(ctx.Writer); err != nil { + } else if validResponse, ok := response.(GetTitleResponseObject); ok { + if err := validResponse.VisitGetTitleResponse(ctx.Writer); err != nil { ctx.Error(err) } } else if response != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index 97fa3a4..b2a2df0 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: - id: + user_id: type: integer - format: int64 + format: int32 description: Unique user ID (primary key) example: 1 avatar_id: type: integer - format: int64 + format: int32 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 new file mode 100644 index 0000000..366f298 --- /dev/null +++ b/modules/backend/handlers.go @@ -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), + } +} diff --git a/modules/backend/handlers/users.go b/modules/backend/handlers/users.go deleted file mode 100644 index b67153d..0000000 --- a/modules/backend/handlers/users.go +++ /dev/null @@ -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 -} diff --git a/modules/backend/main.go b/modules/backend/main.go index 42a66d3..a9e8db0 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -9,7 +9,6 @@ import ( "time" oapi "nyanimedb/api" - handlers "nyanimedb/modules/backend/handlers" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -43,7 +42,7 @@ func main() { queries := sqlc.New(conn) - server := handlers.NewServer(queries) + server := NewServer(queries) // r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index b1dd8af..7c9c197 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -1,17 +1,17 @@ -- name: GetImageByID :one -SELECT id, storage_type, image_path +SELECT image_id, storage_type, image_path FROM images -WHERE id = $1; +WHERE image_id = $1; --- name: CreateImage :one -INSERT INTO images (storage_type, image_path) -VALUES ($1, $2) -RETURNING id, storage_type, image_path; +-- -- name: CreateImage :one +-- INSERT INTO images (storage_type, image_path) +-- VALUES ($1, $2) +-- RETURNING image_id, storage_type, image_path; --- name: GetUserByID :one -SELECT id, avatar_id, mail, nickname, disp_name, user_desc, creation_date -FROM users -WHERE id = $1; +-- -- name: GetUserByID :one +-- SELECT user_id, avatar_id, passhash, mail, nickname, disp_name, user_desc, creation_date +-- FROM users +-- WHERE user_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 6a06afb..b1789ed 100644 --- a/modules/frontend/package-lock.json +++ b/modules/frontend/package-lock.json @@ -10,8 +10,7 @@ "dependencies": { "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-dom": "^19.1.1" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -2062,15 +2061,6 @@ "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", @@ -3356,7 +3346,6 @@ "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" }, @@ -3374,44 +3363,6 @@ "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", @@ -3515,12 +3466,6 @@ "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 b4977aa..c40ff17 100644 --- a/modules/frontend/package.json +++ b/modules/frontend/package.json @@ -12,8 +12,7 @@ "dependencies": { "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.9.4" + "react-dom": "^19.1.1" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/modules/frontend/src/App.tsx b/modules/frontend/src/App.tsx index a88ad57..6b2ee5f 100644 --- a/modules/frontend/src/App.tsx +++ b/modules/frontend/src/App.tsx @@ -1,15 +1,8 @@ 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; \ No newline at end of file +export default App; diff --git a/modules/frontend/src/components/UserPage/UserPage.tsx b/modules/frontend/src/components/UserPage/UserPage.tsx index 0a83679..b0db90c 100644 --- a/modules/frontend/src/components/UserPage/UserPage.tsx +++ b/modules/frontend/src/components/UserPage/UserPage.tsx @@ -1,21 +1,17 @@ import React, { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; // <-- import -import { DefaultService } from "../../api/services/DefaultService"; +import { DefaultService } from "../../api/services/DefaultService"; // adjust path 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(id, "all"); // <-- use dynamic id + const userInfo = await DefaultService.getUsers("1", "all"); setUser(userInfo); } catch (err) { console.error(err); @@ -25,7 +21,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 dc52d23..d9f23c0 100644 --- a/sql/migrations/000001_init.down.sql +++ b/sql/migrations/000001_init.down.sql @@ -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 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 0b7fa33..93ce071 100644 --- a/sql/migrations/000001_init.up.sql +++ b/sql/migrations/000001_init.up.sql @@ -1,141 +1,99 @@ -- TODO: +-- title table triggers -- maybe jsonb constraints --- clean unused images +-- actions (delete) 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 ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - provider_name text NOT NULL, - credentials jsonb + provider_id serial PRIMARY KEY, + provider_name varchar(64) NOT NULL + -- token ); CREATE TABLE tags ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - tag_names jsonb NOT NULL + tag_id serial PRIMARY KEY, + tag_names jsonb NOT NULL --mb constraints ); + +-- clean unused images CREATE TABLE images ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - storage_type storage_type_t NOT NULL, - image_path text UNIQUE NOT NULL + image_id serial PRIMARY KEY, + storage_type storage_type_t NOT NULL, + image_path varchar(256) UNIQUE NOT NULL ); CREATE TABLE users ( - 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 + 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 ); CREATE TABLE studios ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - studio_name text UNIQUE, - illust_id bigint REFERENCES images (id), - studio_desc text + studio_id serial PRIMARY KEY, + studio_name varchar(64) UNIQUE, + illust_id int REFERENCES images (image_id), + studio_desc text ); CREATE TABLE titles ( - 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, + 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, 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 ( - 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 + 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 ); CREATE TABLE title_tags ( - PRIMARY KEY (title_id, tag_id), - title_id bigint NOT NULL REFERENCES titles (id), - tag_id bigint NOT NULL REFERENCES tags (id) + PRIMARY KEY (title_id, tag_id), + title_id int NOT NULL REFERENCES titles, + tag_id int NOT NULL REFERENCES tags ); CREATE TABLE signals ( - 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 + 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 diff --git a/sql/models.go b/sql/models.go index 928d5ac..4bed173 100644 --- a/sql/models.go +++ b/sql/models.go @@ -7,7 +7,6 @@ package sqlc import ( "database/sql/driver" "fmt" - "time" "github.com/jackc/pgx/v5/pgtype" ) @@ -186,42 +185,48 @@ func (ns NullUsertitleStatusT) Value() (driver.Value, error) { } type Image struct { - ID int64 `json:"id"` + ImageID int32 `json:"image_id"` StorageType StorageTypeT `json:"storage_type"` ImagePath string `json:"image_path"` } type Provider struct { - ID int64 `json:"id"` + ProviderID int32 `json:"provider_id"` 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 { - ID int64 `json:"id"` - TitleID *int64 `json:"title_id"` + SignalID int32 `json:"signal_id"` RawData []byte `json:"raw_data"` - ProviderID int64 `json:"provider_id"` - Pending bool `json:"pending"` + ProviderID int32 `json:"provider_id"` + Dirty bool `json:"dirty"` } type Studio struct { - ID int64 `json:"id"` + StudioID int32 `json:"studio_id"` StudioName *string `json:"studio_name"` - IllustID *int64 `json:"illust_id"` + IllustID *int32 `json:"illust_id"` StudioDesc *string `json:"studio_desc"` } type Tag struct { - ID int64 `json:"id"` + TagID int32 `json:"tag_id"` TagNames []byte `json:"tag_names"` } type Title struct { - ID int64 `json:"id"` + TitleID int32 `json:"title_id"` TitleNames []byte `json:"title_names"` - StudioID int64 `json:"studio_id"` - PosterID *int64 `json:"poster_id"` + StudioID int32 `json:"studio_id"` + PosterID *int32 `json:"poster_id"` TitleStatus TitleStatusT `json:"title_status"` Rating *float64 `json:"rating"` RatingCount *int32 `json:"rating_count"` @@ -234,27 +239,26 @@ type Title struct { } type TitleTag struct { - TitleID int64 `json:"title_id"` - TagID int64 `json:"tag_id"` + TitleID int32 `json:"title_id"` + TagID int32 `json:"tag_id"` } type User struct { - 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"` + 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"` } type Usertitle struct { - UserID int64 `json:"user_id"` - TitleID int64 `json:"title_id"` - Status UsertitleStatusT `json:"status"` - Rate *int32 `json:"rate"` - ReviewText *string `json:"review_text"` - ReviewDate pgtype.Timestamptz `json:"review_date"` + 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"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 8f92c2a..2b00bef 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -7,67 +7,17 @@ package sqlc import ( "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 -SELECT id, storage_type, image_path +SELECT image_id, storage_type, image_path FROM images -WHERE id = $1 +WHERE image_id = $1 ` -func (q *Queries) GetImageByID(ctx context.Context, id int64) (Image, error) { - row := q.db.QueryRow(ctx, getImageByID, id) +func (q *Queries) GetImageByID(ctx context.Context, imageID int32) (Image, error) { + row := q.db.QueryRow(ctx, getImageByID, imageID) 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, - ) + err := row.Scan(&i.ImageID, &i.StorageType, &i.ImagePath) return i, err } diff --git a/sql/sqlc.yaml b/sql/sqlc.yaml index f44761e..23b22f9 100644 --- a/sql/sqlc.yaml +++ b/sql/sqlc.yaml @@ -11,17 +11,4 @@ sql: sql_package: "pgx/v5" sql_driver: "github.com/jackc/pgx/v5" emit_json_tags: 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" \ No newline at end of file + emit_pointers_for_null_types: true \ No newline at end of file