11 Commits

Author SHA1 Message Date
f81e4eaa47 feat: NewWishListService constructor implemented 2025-09-01 18:47:30 +03:00
14bad8e7ef feat: Implemented create wish method for wish list service 2025-09-01 18:46:08 +03:00
3198612e16 refactor: sql queries related to privacy-accounting;
chore: regenerated swagger;
feat: utilizing new 410 error when user is banned/unavailable/deleted
2025-08-23 19:17:05 +03:00
dd2960a742 refactor: began refactoring access control in sql 2025-08-16 22:21:41 +03:00
c7a440e38f feat: database method to move wish to another list 2025-08-15 14:34:28 +03:00
d12162fc3b feat: mapper function for wish dto;
refactor: made guid foreign key for wish object for more ease of use
2025-08-15 13:53:02 +03:00
711b1ad5d1 feat: mapper function for wishlist dto;
refactor: made database fields for wishlist object not null
2025-08-13 21:22:18 +03:00
af69c4fe07 feat: initialized wishlist service 2025-08-12 23:53:19 +03:00
5eb90b18d5 feat: sorting and filtering enums 2025-08-10 23:54:16 +03:00
bd90fb339f feat: mapstructure tags for wishlist service dtos 2025-08-07 14:37:11 +03:00
e4c879db36 feat: dtos for wishList service;
feat: validator for guid;
feat: created interface for wishlist service
2025-08-06 17:36:31 +03:00
20 changed files with 817 additions and 76 deletions

View File

@@ -748,7 +748,7 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -744,7 +744,7 @@
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -157,7 +157,7 @@ definitions:
models.RegistrationCompleteRequest: models.RegistrationCompleteRequest:
properties: properties:
birthday: birthday:
type: string type: integer
name: name:
type: string type: string
username: username:

View File

@@ -52,6 +52,7 @@ require (
github.com/minio/crc64nvme v1.0.2 // indirect github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.95 // indirect github.com/minio/minio-go/v7 v7.0.95 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect

View File

@@ -99,6 +99,8 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

View File

@@ -120,8 +120,12 @@ func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
response, err := ctrl.ps.GetProfileByUsername(cinfo, username); if err != nil { response, err := ctrl.ps.GetProfileByUsername(cinfo, username); if err != nil {
if errors.Is(err, errs.ErrNotFound) { if errors.Is(err, errs.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
} else if errors.Is(err, errs.ErrUnauthorized) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Profile avaiable to authrorized users only"})
} else if errors.Is(err, errs.ErrForbidden) { } else if errors.Is(err, errs.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access restricted by profile's privacy settings"}) c.JSON(http.StatusForbidden, gin.H{"error": "Access restricted by profile's privacy settings"})
} else if errors.Is(err, errs.ErrGone) {
c.JSON(http.StatusGone, gin.H{"error": "Profile no longer available"})
} else { } else {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }

View File

@@ -88,6 +88,7 @@ type Wish struct {
ID int64 ID int64
Guid pgtype.UUID Guid pgtype.UUID
WishListID int64 WishListID int64
WishListGuid pgtype.UUID
Name string Name string
Description string Description string
PictureUrl string PictureUrl string
@@ -104,8 +105,8 @@ type WishList struct {
ProfileID int64 ProfileID int64
Hidden bool Hidden bool
Name string Name string
IconName *string IconName string
Color *string Color string
ColorGrad *string ColorGrad string
Deleted bool Deleted bool
} }

View File

@@ -11,6 +11,51 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const checkProfileAccess = `-- name: CheckProfileAccess :one
SELECT
CASE WHEN u.deleted OR NOT u.verified THEN TRUE ELSE FALSE END AS user_unavailable,
CASE WHEN EXISTS (
SELECT 1
FROM banned_users
WHERE user_id = u.id AND
pardoned IS FALSE AND
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
) THEN TRUE ELSE FALSE END AS user_banned,
CASE WHEN ps.hide_profile_details THEN TRUE ELSE FALSE END AS hidden,
CASE WHEN ps.hide_for_unauthenticated AND $2::text = '' THEN TRUE ELSE FALSE END AS auth_required,
CASE WHEN ps.captcha THEN TRUE ELSE FALSE END AS captcha_required
FROM profiles p
JOIN profile_settings ps ON ps.profile_id = p.id
JOIN users u ON p.user_id = u.id
WHERE p.id = $1
`
type CheckProfileAccessParams struct {
ID int64
Requester string
}
type CheckProfileAccessRow struct {
UserUnavailable bool
UserBanned bool
Hidden bool
AuthRequired bool
CaptchaRequired bool
}
func (q *Queries) CheckProfileAccess(ctx context.Context, arg CheckProfileAccessParams) (CheckProfileAccessRow, error) {
row := q.db.QueryRow(ctx, checkProfileAccess, arg.ID, arg.Requester)
var i CheckProfileAccessRow
err := row.Scan(
&i.UserUnavailable,
&i.UserBanned,
&i.Hidden,
&i.AuthRequired,
&i.CaptchaRequired,
)
return i, err
}
const checkUserRegistrationAvailability = `-- name: CheckUserRegistrationAvailability :one const checkUserRegistrationAvailability = `-- name: CheckUserRegistrationAvailability :one
SELECT SELECT
COUNT(CASE WHEN users.username = $1::text THEN 1 END) > 0 AS username_busy, COUNT(CASE WHEN users.username = $1::text THEN 1 END) > 0 AS username_busy,
@@ -53,6 +98,46 @@ func (q *Queries) CheckUserRegistrationAvailability(ctx context.Context, arg Che
return i, err return i, err
} }
const checkWishAccessByGuid = `-- name: CheckWishAccessByGuid :one
SELECT EXISTS (
SELECT 1
FROM wishes w
JOIN wish_lists wl ON w.wish_list_id = wl.id
JOIN profiles p ON wl.profile_id = p.id
JOIN profile_settings ps ON ps.profile_id = p.id
JOIN users u ON p.user_id = u.id
LEFT JOIN banned_users bu ON u.id = bu.user_id
AND bu.pardoned = FALSE
AND (bu.expires_at IS NULL OR bu.expires_at > NOW())
WHERE w.guid = ($1::text)::uuid
AND ps.hide_profile_details = FALSE
AND (
$2::text != ''
OR ps.hide_for_unauthenticated IS FALSE
)
AND (
w.fulfilled = FALSE
OR ps.hide_fulfilled IS FALSE
)
AND w.deleted = FALSE
AND wl.deleted = FALSE
AND u.deleted = FALSE
AND bu.id IS NULL -- Ensures owner is not banned
)
`
type CheckWishAccessByGuidParams struct {
Guid string
Requester string
}
func (q *Queries) CheckWishAccessByGuid(ctx context.Context, arg CheckWishAccessByGuidParams) (bool, error) {
row := q.db.QueryRow(ctx, checkWishAccessByGuid, arg.Guid, arg.Requester)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const createBannedUser = `-- name: CreateBannedUser :one const createBannedUser = `-- name: CreateBannedUser :one
INSERT INTO banned_users(user_id, expires_at, reason, banned_by) INSERT INTO banned_users(user_id, expires_at, reason, banned_by)
VALUES ( $1, $2, $3, $4) RETURNING id, user_id, date, reason, expires_at, banned_by, pardoned, pardoned_by VALUES ( $1, $2, $3, $4) RETURNING id, user_id, date, reason, expires_at, banned_by, pardoned, pardoned_by
@@ -253,6 +338,60 @@ func (q *Queries) CreateUser(ctx context.Context, username string) (User, error)
return i, err return i, err
} }
const createWish = `-- name: CreateWish :one
INSERT INTO wishes(
wish_list_id,
wish_list_guid,
name,
description,
picture_url,
stars)
VALUES
(
(SELECT id FROM wish_lists wl WHERE wl.guid = ($1::text)::uuid),
($1::text)::uuid,
$2::text,
$3::text,
$4::text,
$5::smallint
)
RETURNING id, guid, wish_list_id, wish_list_guid, name, description, picture_url, stars, creation_date, fulfilled, fulfilled_date, deleted
`
type CreateWishParams struct {
WishListGuid string
Name string
Description string
PictureUrl string
Stars int16
}
func (q *Queries) CreateWish(ctx context.Context, arg CreateWishParams) (Wish, error) {
row := q.db.QueryRow(ctx, createWish,
arg.WishListGuid,
arg.Name,
arg.Description,
arg.PictureUrl,
arg.Stars,
)
var i Wish
err := row.Scan(
&i.ID,
&i.Guid,
&i.WishListID,
&i.WishListGuid,
&i.Name,
&i.Description,
&i.PictureUrl,
&i.Stars,
&i.CreationDate,
&i.Fulfilled,
&i.FulfilledDate,
&i.Deleted,
)
return i, err
}
const createWishList = `-- name: CreateWishList :one const createWishList = `-- name: CreateWishList :one
INSERT INTO wish_lists(profile_id, hidden, name, icon_name, color, color_grad) INSERT INTO wish_lists(profile_id, hidden, name, icon_name, color, color_grad)
VALUES ( VALUES (
@@ -401,34 +540,15 @@ SELECT
p.avatar_url, p.avatar_url,
CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday, CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
p.color, p.color,
p.color_grad, p.color_grad
NOT ($1::text = '' AND ps.hide_for_unauthenticated) AS access_allowed
FROM FROM
users AS u users AS u
JOIN profiles AS p ON u.id = p.user_id JOIN profiles AS p ON u.id = p.user_id
JOIN profile_settings AS ps ON p.id = ps.profile_id JOIN profile_settings AS ps ON p.id = ps.profile_id
WHERE WHERE
u.username = $2::text u.username = $1::text
AND (
$2::text = $1::text
OR
u.deleted IS FALSE
AND u.verified IS TRUE
AND NOT EXISTS (
SELECT 1
FROM banned_users
WHERE user_id = u.id AND
pardoned IS FALSE AND
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
)
)
` `
type GetProfileByUsernameWithPrivacyParams struct {
Requester string
SearchedUsername string
}
type GetProfileByUsernameWithPrivacyRow struct { type GetProfileByUsernameWithPrivacyRow struct {
Username string Username string
Name string Name string
@@ -437,11 +557,10 @@ type GetProfileByUsernameWithPrivacyRow struct {
Birthday pgtype.Timestamp Birthday pgtype.Timestamp
Color string Color string
ColorGrad string ColorGrad string
AccessAllowed *bool
} }
func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, arg GetProfileByUsernameWithPrivacyParams) (GetProfileByUsernameWithPrivacyRow, error) { func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, searchedUsername string) (GetProfileByUsernameWithPrivacyRow, error) {
row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, arg.Requester, arg.SearchedUsername) row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, searchedUsername)
var i GetProfileByUsernameWithPrivacyRow var i GetProfileByUsernameWithPrivacyRow
err := row.Scan( err := row.Scan(
&i.Username, &i.Username,
@@ -451,7 +570,6 @@ func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, arg GetPr
&i.Birthday, &i.Birthday,
&i.Color, &i.Color,
&i.ColorGrad, &i.ColorGrad,
&i.AccessAllowed,
) )
return i, err return i, err
} }
@@ -836,7 +954,7 @@ WHERE
FROM banned_users FROM banned_users
WHERE user_id = users.id AND WHERE user_id = users.id AND
pardoned IS FALSE AND pardoned IS FALSE AND
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP) (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
) AND -- Not banned ) AND -- Not banned
linfo.password_hash = crypt($2::text, linfo.password_hash) linfo.password_hash = crypt($2::text, linfo.password_hash)
` `
@@ -911,6 +1029,53 @@ func (q *Queries) GetValidUserSessions(ctx context.Context, userID int64) ([]Ses
return items, nil return items, nil
} }
const getWishByGuid = `-- name: GetWishByGuid :one
SELECT id, guid, wish_list_id, wish_list_guid, name, description, picture_url, stars, creation_date, fulfilled, fulfilled_date, deleted FROM wishes w
WHERE w.guid = ($1::text)::uuid
`
func (q *Queries) GetWishByGuid(ctx context.Context, guid string) (Wish, error) {
row := q.db.QueryRow(ctx, getWishByGuid, guid)
var i Wish
err := row.Scan(
&i.ID,
&i.Guid,
&i.WishListID,
&i.WishListGuid,
&i.Name,
&i.Description,
&i.PictureUrl,
&i.Stars,
&i.CreationDate,
&i.Fulfilled,
&i.FulfilledDate,
&i.Deleted,
)
return i, err
}
const getWishListOwnerByGuid = `-- name: GetWishListOwnerByGuid :one
SELECT u.id, u.username, u.verified, u.registration_date, u.role, u.deleted
FROM wish_lists wl
JOIN profiles p ON wl.profile_id = p.id
JOIN users u ON p.user_id = u.id
WHERE wl.guid = ($1::text)::uuid
`
func (q *Queries) GetWishListOwnerByGuid(ctx context.Context, guid string) (User, error) {
row := q.db.QueryRow(ctx, getWishListOwnerByGuid, guid)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.Verified,
&i.RegistrationDate,
&i.Role,
&i.Deleted,
)
return i, err
}
const getWishlistByGuid = `-- name: GetWishlistByGuid :one const getWishlistByGuid = `-- name: GetWishlistByGuid :one
SELECT id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted FROM wish_lists wl SELECT id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted FROM wish_lists wl
WHERE wl.guid = ($1::text)::uuid WHERE wl.guid = ($1::text)::uuid
@@ -933,11 +1098,93 @@ func (q *Queries) GetWishlistByGuid(ctx context.Context, guid string) (WishList,
return i, err return i, err
} }
const getWishlistsByUsername = `-- name: GetWishlistsByUsername :many
SELECT wl.id, guid, profile_id, hidden, wl.name, icon_name, wl.color, wl.color_grad, wl.deleted, p.id, user_id, p.name, bio, avatar_url, birthday, p.color, p.color_grad, u.id, username, verified, registration_date, role, u.deleted FROM wish_lists wl
JOIN profiles p ON p.id = wl.profile_id
JOIN users u ON u.id = p.user_id
WHERE u.username = $1::text
`
type GetWishlistsByUsernameRow struct {
ID int64
Guid pgtype.UUID
ProfileID int64
Hidden bool
Name string
IconName string
Color string
ColorGrad string
Deleted bool
ID_2 int64
UserID int64
Name_2 string
Bio string
AvatarUrl string
Birthday pgtype.Timestamp
Color_2 string
ColorGrad_2 string
ID_3 int64
Username string
Verified bool
RegistrationDate pgtype.Timestamp
Role int32
Deleted_2 *bool
}
func (q *Queries) GetWishlistsByUsername(ctx context.Context, username string) ([]GetWishlistsByUsernameRow, error) {
rows, err := q.db.Query(ctx, getWishlistsByUsername, username)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetWishlistsByUsernameRow
for rows.Next() {
var i GetWishlistsByUsernameRow
if err := rows.Scan(
&i.ID,
&i.Guid,
&i.ProfileID,
&i.Hidden,
&i.Name,
&i.IconName,
&i.Color,
&i.ColorGrad,
&i.Deleted,
&i.ID_2,
&i.UserID,
&i.Name_2,
&i.Bio,
&i.AvatarUrl,
&i.Birthday,
&i.Color_2,
&i.ColorGrad_2,
&i.ID_3,
&i.Username,
&i.Verified,
&i.RegistrationDate,
&i.Role,
&i.Deleted_2,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWishlistsByUsernameWithPrivacy = `-- name: GetWishlistsByUsernameWithPrivacy :many const getWishlistsByUsernameWithPrivacy = `-- name: GetWishlistsByUsernameWithPrivacy :many
SELECT SELECT
wl.id, wl.guid, wl.profile_id, wl.hidden, wl.name, wl.icon_name, wl.color, wl.color_grad, wl.deleted, wl.id, wl.guid, wl.profile_id, wl.hidden, wl.name, wl.icon_name, wl.color, wl.color_grad, wl.deleted,
CASE CASE
WHEN (ps.hide_profile_details OR ps.hide_for_unauthenticated) THEN FALSE WHEN (
ps.hide_profile_details OR (
ps.hide_for_unauthenticated AND
$1::text = ''
)
) THEN FALSE
ELSE TRUE ELSE TRUE
END AS access_allowed END AS access_allowed
FROM FROM
@@ -950,9 +1197,9 @@ JOIN
users AS u ON p.user_id = u.id users AS u ON p.user_id = u.id
WHERE WHERE
wl.deleted IS FALSE AND wl.deleted IS FALSE AND
u.username = $1::text AND u.username = $2::text AND
( (
u.username = $2::text OR u.username = $1::text OR
(u.verified IS TRUE AND (u.verified IS TRUE AND
NOT EXISTS ( NOT EXISTS (
SELECT 1 SELECT 1
@@ -965,8 +1212,8 @@ WHERE
` `
type GetWishlistsByUsernameWithPrivacyParams struct { type GetWishlistsByUsernameWithPrivacyParams struct {
Username string
Requester string Requester string
Username string
} }
type GetWishlistsByUsernameWithPrivacyRow struct { type GetWishlistsByUsernameWithPrivacyRow struct {
@@ -975,15 +1222,15 @@ type GetWishlistsByUsernameWithPrivacyRow struct {
ProfileID int64 ProfileID int64
Hidden bool Hidden bool
Name string Name string
IconName *string IconName string
Color *string Color string
ColorGrad *string ColorGrad string
Deleted bool Deleted bool
AccessAllowed bool AccessAllowed bool
} }
func (q *Queries) GetWishlistsByUsernameWithPrivacy(ctx context.Context, arg GetWishlistsByUsernameWithPrivacyParams) ([]GetWishlistsByUsernameWithPrivacyRow, error) { func (q *Queries) GetWishlistsByUsernameWithPrivacy(ctx context.Context, arg GetWishlistsByUsernameWithPrivacyParams) ([]GetWishlistsByUsernameWithPrivacyRow, error) {
rows, err := q.db.Query(ctx, getWishlistsByUsernameWithPrivacy, arg.Username, arg.Requester) rows, err := q.db.Query(ctx, getWishlistsByUsernameWithPrivacy, arg.Requester, arg.Username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1013,6 +1260,34 @@ func (q *Queries) GetWishlistsByUsernameWithPrivacy(ctx context.Context, arg Get
return items, nil return items, nil
} }
const moveWishToWishListWithGuid = `-- name: MoveWishToWishListWithGuid :one
WITH updated AS (
UPDATE wishes w
SET
wish_list_id = wl.id,
wish_list_guid = ($1::text)::uuid
FROM wish_lists wl
WHERE
wl.guid = ($1::text)::uuid AND
wl.profile_id = ( -- Make sure the wish is not moved to another profile
SELECT profile_id
FROM wish_lists
WHERE wish_lists.id = w.wish_list_id
)
RETURNING w.id
)
SELECT
COUNT(*) > 0 AS target_found
FROM updated
`
func (q *Queries) MoveWishToWishListWithGuid(ctx context.Context, wishListGuid string) (bool, error) {
row := q.db.QueryRow(ctx, moveWishToWishListWithGuid, wishListGuid)
var target_found bool
err := row.Scan(&target_found)
return target_found, err
}
const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec
DELETE FROM confirmation_codes DELETE FROM confirmation_codes
WHERE expires_at < CURRENT_TIMESTAMP WHERE expires_at < CURRENT_TIMESTAMP
@@ -1280,7 +1555,9 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
const updateUserByUsername = `-- name: UpdateUserByUsername :exec const updateUserByUsername = `-- name: UpdateUserByUsername :exec
UPDATE users UPDATE users
SET verified = $2, deleted = $3 SET
verified = COALESCE($2, verified),
deleted = COALESCE($3, deleted)
WHERE username = $1 WHERE username = $1
` `

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dto
type WishListDto struct {
Guid string `json:"guid" mapstructure:"guid"`
Name string `json:"name" mapstructure:"name"`
Hidden bool `json:"hidden" mapstructure:"hidden"`
IconName string `json:"icon_name" mapstructure:"icon_name"`
Color string `json:"color" mapstructure:"color"`
ColorGrad string `json:"color_grad" mapstructure:"color_grad"`
}
type NewWishListDto struct {
Name string `json:"name" mapstructure:"name" binding:"required" validate:"max=32"`
Hidden bool `json:"hidden" mapstructure:"hidden"`
IconName string `json:"icon_name" mapstructure:"icon_name" validate:"omitempty,max=64"`
Color string `json:"color" mapstructure:"color" validate:"omitempty,color_hex"`
ColorGrad string `json:"color_grad" mapstructure:"color_grad" validate:"omitempty,color_hex"`
}
type WishDto struct {
Guid string `json:"guid" mapstructure:"guid"`
WishListGuid string `json:"wish_list_guid" mapstructure:"wish_list_guid"`
Name string `json:"name" mapstructure:"name"`
Description string `json:"description" mapstructure:"description"`
PictureUrl string `json:"picture_url" mapstructure:"picture_url"`
Stars int `json:"stars" mapstructure:"stars"`
CreationDate int64 `json:"creation_date" mapstructure:"creation_date"`
Fulfilled bool `json:"fulfilled" mapstructure:"fulfilled"`
FulfilledDate int64 `json:"fulfilled_date" mapstructure:"fulfilled_date"`
}
type NewWishDto struct {
WishListGuid string `json:"wish_list_guid" mapstructure:"wish_list_guid" binding:"required" validate:"guid"`
Name string `json:"name" mapstructure:"name" binding:"required" validate:"max=64"`
Description string `json:"description" mapstructure:"description" validate:"omitempty,max=1000"`
PictureUploadId string `json:"picture_upload_id" mapstructure:"picture_upload_id" validate:"omitempty,upload_id=image"`
Stars int `json:"stars" mapstructure:"stars" validate:"min=1,max=5"`
}

View File

@@ -27,4 +27,5 @@ var (
ErrForbidden = errors.New("Access is denied") ErrForbidden = errors.New("Access is denied")
ErrTooManyRequests = errors.New("Too many requests") ErrTooManyRequests = errors.New("Too many requests")
ErrNotFound = errors.New("Resource not found") ErrNotFound = errors.New("Resource not found")
ErrGone = errors.New("Resource no longer available")
) )

View File

@@ -79,10 +79,7 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username
} }
defer helper.Rollback() defer helper.Rollback()
profileRow, err := db.TXQueries.GetProfileByUsernameWithPrivacy(db.CTX, database.GetProfileByUsernameWithPrivacyParams{ profileRow, err := db.TXQueries.GetProfileByUsername(db.CTX, username); if err != nil {
Requester: cinfo.Username,
SearchedUsername: username,
}); if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
@@ -94,9 +91,33 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
if !*profileRow.AccessAllowed { accessChecks, err := db.TXlessQueries.CheckProfileAccess(db.CTX, database.CheckProfileAccessParams{
Requester: cinfo.Username,
ID: profileRow.ID,
}); if err != nil {
p.log.Error(
"Failed to check access for given profile",
zap.String("profile_owner_username", username),
zap.String("requester", cinfo.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
if accessChecks.AuthRequired {
return nil, errs.ErrUnauthorized
}
if accessChecks.Hidden {
return nil, errs.ErrForbidden return nil, errs.ErrForbidden
} }
if accessChecks.UserBanned {
return nil, errs.ErrGone
}
if accessChecks.UserUnavailable {
return nil, errs.ErrGone
}
if accessChecks.CaptchaRequired {
p.log.Warn("Captcha check is not implemented")
}
profileDto := &dto.ProfileDto{ profileDto := &dto.ProfileDto{
Name: profileRow.Name, Name: profileRow.Name,

View File

@@ -48,7 +48,7 @@ type s3ServiceImpl struct {
imagePolicy minio.PostPolicy imagePolicy minio.PostPolicy
} }
func NewUploadService(_minio *minio.Client, _log *zap.Logger) S3Service { func NewS3Service(_minio *minio.Client, _log *zap.Logger) S3Service {
service := s3ServiceImpl{ service := s3ServiceImpl{
minio: _minio, minio: _minio,
log: _log, log: _log,

View File

@@ -23,9 +23,10 @@ import (
var Module = fx.Module("services", var Module = fx.Module("services",
fx.Provide( fx.Provide(
NewUploadService, NewS3Service,
NewSmtpService, NewSmtpService,
NewAuthService, NewAuthService,
NewProfileService, NewProfileService,
NewWishListService,
), ),
) )

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package services
import (
"easywish/internal/database"
"easywish/internal/dto"
errs "easywish/internal/errors"
"easywish/internal/utils"
"easywish/internal/utils/enums"
mapspecial "easywish/internal/utils/mapSpecial"
"errors"
"github.com/go-redis/redis/v8"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
type wishListServiceImpl struct {
log *zap.Logger
dbctx database.DbContext
redis *redis.Client
s3 S3Service
}
type WishListService interface {
CreateWishList(cinfo dto.ClientInfo, object dto.NewWishListDto) (*dto.WishListDto, error)
UpdateWishListByGuid(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error)
DeleteWishListByGuid(cinfo dto.ClientInfo, guid string) (bool, error)
GetWishListByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishListDto, error)
GetUserWishListsPaginated(cinfo dto.ClientInfo, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishListDto, error)
CreateWish(cinfo dto.ClientInfo, object dto.NewWishDto) (*dto.WishDto, error)
UpdateWish(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error)
MoveWishToWishList(cinfo dto.ClientInfo, wishGuid string, wishListGuid string) (bool, error)
GetWishByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishDto, error)
GetWishesByWishListGuidPaginated(cinfo dto.ClientInfo, guid string, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishDto, error)
}
func NewWishListService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _s3 S3Service) WishListService {
return wishListServiceImpl{
log: _log,
dbctx: _dbctx,
redis: _redis,
s3: _s3,
}
}
func (w wishListServiceImpl) CreateWish(cinfo dto.ClientInfo, object dto.NewWishDto) (*dto.WishDto, error) {
helper, db, err := database.NewDbHelperTransaction(w.dbctx); if err != nil {
w.log.Error(
"Failed to open transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.Rollback()
// Check if wish list exists
wishList, err := db.TXQueries.GetWishlistByGuid(db.CTX, object.WishListGuid); if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
w.log.Warn(
"Attempted to create a wish for a wish list that does not exist",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid),
zap.Error(err))
return nil, errs.ErrNotFound
}
w.log.Error(
"Failed to get wishlist for the new wish",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid),
zap.Error(err))
return nil, errs.ErrServerError
}
wishListOwnerUser, err := db.TXQueries.GetWishListOwnerByGuid(db.CTX, wishList.Guid.String()); if err != nil {
w.log.Error(
"Failed to get wish list owner",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", wishList.Guid.String()),
zap.Error(err))
return nil, errs.ErrServerError
}
if wishListOwnerUser.Username != cinfo.Username {
w.log.Warn(
"Attempt to create wish in a wish list the user does not own",
zap.String("owner_username", wishListOwnerUser.Username),
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid))
// As usual, we will pretend that it does not exist
return nil, errs.ErrNotFound
}
var avatarUrl *string
if object.PictureUploadId != "" {
key, err := w.s3.SaveUpload(object.PictureUploadId, "images"); if err != nil {
if errors.Is(err, errs.ErrFileNotFound) {
return nil, err
}
w.log.Error(
"Failed to save image",
zap.String("upload_id", object.PictureUploadId),
zap.String("username", cinfo.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
urlObj := w.s3.GetLocalizedFileUrl(*key, "images")
avatarUrl = utils.NewPointer(urlObj.String())
} else {
avatarUrl = utils.NewPointer("")
}
newWish, err := db.TXQueries.CreateWish(db.CTX, database.CreateWishParams{
WishListGuid: object.WishListGuid,
Name: object.Name,
Description: object.Description,
PictureUrl: *avatarUrl,
Stars: int16(object.Stars),
}); if err != nil {
w.log.Error(
"Failed to create a new wish",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid),
zap.Error(err))
return nil, errs.ErrServerError
}
err = helper.Commit(); if err != nil {
w.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
wishDto := &dto.WishDto{}
mapspecial.MapWishDto(newWish, wishDto)
return wishDto, nil
}
func (w wishListServiceImpl) CreateWishList(cinfo dto.ClientInfo, object dto.NewWishListDto) (*dto.WishListDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) DeleteWishListByGuid(cinfo dto.ClientInfo, guid string) (bool, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetUserWishListsPaginated(cinfo dto.ClientInfo, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishListDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetWishByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetWishListByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishListDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetWishesByWishListGuidPaginated(cinfo dto.ClientInfo, guid string, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) MoveWishToWishList(cinfo dto.ClientInfo, wishGuid string, wishListGuid string) (bool, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) UpdateWish(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) UpdateWishListByGuid(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error) {
panic("unimplemented")
}

View File

@@ -35,3 +35,15 @@ const (
JwtAccessTokenType JwtTokenType = iota JwtAccessTokenType JwtTokenType = iota
JwtRefreshTokenType JwtRefreshTokenType
) )
type Sorting int32
const (
ByDate Sorting = iota
ByScore
)
type SortOrder int32
const (
Descending = iota
Ascending
)

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package mapspecial
import (
"easywish/internal/database"
"easywish/internal/dto"
"github.com/rafiulgits/go-automapper"
)
func MapWishDto(dbModel database.Wish, dtoModel *dto.WishDto) {
if dtoModel == nil {
dtoModel = &dto.WishDto{}
}
automapper.Map(&dbModel, dtoModel, func(src *database.Wish, dst *dto.WishDto) {
dst.Guid = src.Guid.String()
dst.WishListGuid = src.WishListGuid.String()
dst.CreationDate = src.CreationDate.Time.UnixMilli()
dst.FulfilledDate = src.FulfilledDate.Time.UnixMilli()
})
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package mapspecial
import (
"easywish/internal/database"
"easywish/internal/dto"
"github.com/rafiulgits/go-automapper"
)
func MapWishListDto(dbModel database.WishList, dtoModel *dto.WishListDto) {
if dtoModel == nil {
dtoModel = &dto.WishListDto{}
}
automapper.Map(&dbModel, dtoModel, func(src *database.WishList, dst *dto.WishListDto) {
dst.Guid = src.Guid.String()
})
}

View File

@@ -42,6 +42,13 @@ func GetCustomHandlers() []CustomValidatorHandler {
return regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`).MatchString(username) return regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`).MatchString(username)
}}, }},
{
FieldName: "guid",
Function: func(fl validator.FieldLevel) bool {
guid := fl.Field().String()
return regexp.MustCompile(`^([{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?)$`).MatchString(guid)
}},
{ {
FieldName: "name", FieldName: "name",
Function: func(fl validator.FieldLevel) bool { Function: func(fl validator.FieldLevel) bool {

View File

@@ -280,6 +280,24 @@ SELECT profiles.* FROM profiles
JOIN users ON users.id = profiles.user_id JOIN users ON users.id = profiles.user_id
WHERE users.username = $1; WHERE users.username = $1;
;-- name: CheckProfileAccess :one
SELECT
CASE WHEN u.deleted OR NOT u.verified THEN TRUE ELSE FALSE END AS user_unavailable,
CASE WHEN EXISTS (
SELECT 1
FROM banned_users
WHERE user_id = u.id AND
pardoned IS FALSE AND
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
) THEN TRUE ELSE FALSE END AS user_banned,
CASE WHEN ps.hide_profile_details THEN TRUE ELSE FALSE END AS hidden,
CASE WHEN ps.hide_for_unauthenticated AND @requester::text = '' THEN TRUE ELSE FALSE END AS auth_required,
CASE WHEN ps.captcha THEN TRUE ELSE FALSE END AS captcha_required
FROM profiles p
JOIN profile_settings ps ON ps.profile_id = p.id
JOIN users u ON p.user_id = u.id
WHERE p.id = $1;
;-- name: GetProfileByUsernameWithPrivacy :one ;-- name: GetProfileByUsernameWithPrivacy :one
SELECT SELECT
u.username, u.username,
@@ -288,27 +306,13 @@ SELECT
p.avatar_url, p.avatar_url,
CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday, CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
p.color, p.color,
p.color_grad, p.color_grad
NOT (@requester::text = '' AND ps.hide_for_unauthenticated) AS access_allowed
FROM FROM
users AS u users AS u
JOIN profiles AS p ON u.id = p.user_id JOIN profiles AS p ON u.id = p.user_id
JOIN profile_settings AS ps ON p.id = ps.profile_id JOIN profile_settings AS ps ON p.id = ps.profile_id
WHERE WHERE
u.username = @searched_username::text u.username = @searched_username::text;
AND (
@searched_username::text = @requester::text
OR
u.deleted IS FALSE
AND u.verified IS TRUE
AND NOT EXISTS (
SELECT 1
FROM banned_users
WHERE user_id = u.id AND
pardoned IS FALSE AND
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
)
);
;-- name: GetProfilesRestricted :many ;-- name: GetProfilesRestricted :many
SELECT SELECT
@@ -389,11 +393,29 @@ WHERE wl.guid = (@guid::text)::uuid;
SELECT * FROM wish_lists wl SELECT * FROM wish_lists wl
WHERE wl.guid = (@guid::text)::uuid; WHERE wl.guid = (@guid::text)::uuid;
;-- name: GetWishListOwnerByGuid :one
SELECT u.*
FROM wish_lists wl
JOIN profiles p ON wl.profile_id = p.id
JOIN users u ON p.user_id = u.id
WHERE wl.guid = (@guid::text)::uuid;
;-- name: GetWishlistsByUsername :many
SELECT * FROM wish_lists wl
JOIN profiles p ON p.id = wl.profile_id
JOIN users u ON u.id = p.user_id
WHERE u.username = @username::text;
-- name: GetWishlistsByUsernameWithPrivacy :many -- name: GetWishlistsByUsernameWithPrivacy :many
SELECT SELECT
wl.*, wl.*,
CASE CASE
WHEN (ps.hide_profile_details OR ps.hide_for_unauthenticated) THEN FALSE WHEN (
ps.hide_profile_details OR (
ps.hide_for_unauthenticated AND
@requester::text = ''
)
) THEN FALSE
ELSE TRUE ELSE TRUE
END AS access_allowed END AS access_allowed
FROM FROM
@@ -423,6 +445,25 @@ WHERE
--: Wish Object {{{ --: Wish Object {{{
;-- name: CreateWish :one
INSERT INTO wishes(
wish_list_id,
wish_list_guid,
name,
description,
picture_url,
stars)
VALUES
(
(SELECT id FROM wish_lists wl WHERE wl.guid = (@wish_list_guid::text)::uuid),
(@wish_list_guid::text)::uuid,
@name::text,
@description::text,
@picture_url::text,
@stars::smallint
)
RETURNING *;
;-- name: UpdateWishByGuid :exec ;-- name: UpdateWishByGuid :exec
UPDATE wishes w UPDATE wishes w
SET SET
@@ -435,4 +476,55 @@ SET
deleted = COALESCE(@deleted::boolean, w.deleted) deleted = COALESCE(@deleted::boolean, w.deleted)
WHERE w.guid = (@guid::text)::uuid; WHERE w.guid = (@guid::text)::uuid;
;-- name: MoveWishToWishListWithGuid :one
WITH updated AS (
UPDATE wishes w
SET
wish_list_id = wl.id,
wish_list_guid = (@wish_list_guid::text)::uuid
FROM wish_lists wl
WHERE
wl.guid = (@wish_list_guid::text)::uuid AND
wl.profile_id = ( -- Make sure the wish is not moved to another profile
SELECT profile_id
FROM wish_lists
WHERE wish_lists.id = w.wish_list_id
)
RETURNING w.id
)
SELECT
COUNT(*) > 0 AS target_found
FROM updated;
;-- name: GetWishByGuid :one
SELECT * FROM wishes w
WHERE w.guid = (@guid::text)::uuid;
;-- name: CheckWishAccessByGuid :one
SELECT EXISTS (
SELECT 1
FROM wishes w
JOIN wish_lists wl ON w.wish_list_id = wl.id
JOIN profiles p ON wl.profile_id = p.id
JOIN profile_settings ps ON ps.profile_id = p.id
JOIN users u ON p.user_id = u.id
LEFT JOIN banned_users bu ON u.id = bu.user_id
AND bu.pardoned = FALSE
AND (bu.expires_at IS NULL OR bu.expires_at > NOW())
WHERE w.guid = (@guid::text)::uuid
AND ps.hide_profile_details = FALSE
AND (
@requester::text != ''
OR ps.hide_for_unauthenticated IS FALSE
)
AND (
w.fulfilled = FALSE
OR ps.hide_fulfilled IS FALSE
)
AND w.deleted = FALSE
AND wl.deleted = FALSE
AND u.deleted = FALSE
AND bu.id IS NULL -- Ensures owner is not banned
);
--: }}} --: }}}

View File

@@ -101,9 +101,9 @@ CREATE TABLE IF NOT EXISTS "wish_lists" (
profile_id BIGINT UNIQUE NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, profile_id BIGINT UNIQUE NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
hidden BOOLEAN NOT NULL DEFAULT FALSE, hidden BOOLEAN NOT NULL DEFAULT FALSE,
name VARCHAR(32) NOT NULL DEFAULT 'Wishes', name VARCHAR(32) NOT NULL DEFAULT 'Wishes',
icon_name VARCHAR(64), icon_name VARCHAR(64) NOT NULL DEFAULT '',
color VARCHAR(7), color VARCHAR(7) NOT NULL DEFAULT '',
color_grad VARCHAR(7), color_grad VARCHAR(7) NOT NULL DEFAULT '',
deleted BOOLEAN NOT NULL DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE
); );
@@ -111,7 +111,8 @@ CREATE TABLE IF NOT EXISTS "wishes" (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
guid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), guid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
wish_list_id BIGINT UNIQUE NOT NULL REFERENCES wish_lists(id) ON DELETE CASCADE, wish_list_id BIGINT UNIQUE NOT NULL REFERENCES wish_lists(id) ON DELETE CASCADE,
name VARCHAR(32) NOT NULL DEFAULT 'New wish', wish_list_guid UUID NOT NULL REFERENCES wish_lists(guid) ON DELETE CASCADE,
name VARCHAR(64) NOT NULL DEFAULT 'New wish',
description VARCHAR(1000) NOT NULL DEFAULT '', description VARCHAR(1000) NOT NULL DEFAULT '',
picture_url VARCHAR(512) NOT NULL DEFAULT '', picture_url VARCHAR(512) NOT NULL DEFAULT '',
stars SMALLINT NOT NULL DEFAULT 3 CHECK (stars BETWEEN 1 AND 5), stars SMALLINT NOT NULL DEFAULT 3 CHECK (stars BETWEEN 1 AND 5),