8 Commits

Author SHA1 Message Date
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
12 changed files with 691 additions and 66 deletions

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

@@ -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,52 @@ 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
}
// XXX: recheck, was tired
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,
@@ -253,6 +299,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 +501,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 +518,11 @@ 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) { // FIXME: tweak backend code to handle privacy correctly
row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, arg.Requester, arg.SearchedUsername) func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, searchedUsername string) (GetProfileByUsernameWithPrivacyRow, error) {
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 +532,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 +916,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 +991,102 @@ 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 getWishByGuidWithPrivacy = `-- name: GetWishByGuidWithPrivacy :one
SELECT
w.id, w.guid, w.wish_list_id, w.wish_list_guid, w.name, w.description, w.picture_url, w.stars, w.creation_date, w.fulfilled, w.fulfilled_date, w.deleted,
CASE
WHEN
(
$1::text = u.username OR
NOT ps.hide_profile_details AND
NOT
(
ps.hide_for_unauthenticated AND
$1::text = ''
) AND
NOT wl.hidden
)
THEN TRUE
ELSE FALSE
END AS access_allowed
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
WHERE
w.guid = ($2::text)::uuid AND
w.deleted IS FALSE
`
type GetWishByGuidWithPrivacyParams struct {
Requester string
Guid string
}
type GetWishByGuidWithPrivacyRow struct {
ID int64
Guid pgtype.UUID
WishListID int64
WishListGuid pgtype.UUID
Name string
Description string
PictureUrl string
Stars int16
CreationDate pgtype.Timestamp
Fulfilled bool
FulfilledDate pgtype.Timestamp
Deleted bool
AccessAllowed bool
}
// XXX: Obsolete, use the according access check query instead
func (q *Queries) GetWishByGuidWithPrivacy(ctx context.Context, arg GetWishByGuidWithPrivacyParams) (GetWishByGuidWithPrivacyRow, error) {
row := q.db.QueryRow(ctx, getWishByGuidWithPrivacy, arg.Requester, arg.Guid)
var i GetWishByGuidWithPrivacyRow
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,
&i.AccessAllowed,
)
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 +1109,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 +1208,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 +1223,8 @@ WHERE
` `
type GetWishlistsByUsernameWithPrivacyParams struct { type GetWishlistsByUsernameWithPrivacyParams struct {
Username string
Requester string Requester string
Username string
} }
type GetWishlistsByUsernameWithPrivacyRow struct { type GetWishlistsByUsernameWithPrivacyRow struct {
@@ -975,15 +1233,16 @@ 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
} }
// XXX: Obsolete, use the according access check query instead
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 +1272,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 +1567,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

@@ -0,0 +1,36 @@
// 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/dto"
"easywish/internal/utils/enums"
)
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)
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)
}

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,7 +280,27 @@ 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
-- XXX: recheck, was tired
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
-- FIXME: tweak backend code to handle privacy correctly
SELECT SELECT
u.username, u.username,
p.name, p.name,
@@ -288,27 +308,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 +395,23 @@ 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: 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
-- XXX: Obsolete, create according access check query instead
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 +441,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 +472,56 @@ 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: GetWishByGuidWithPrivacy :one
-- XXX: Obsolete, create according access check query instead
SELECT
w.*,
CASE
WHEN
(
@requester::text = u.username OR
NOT ps.hide_profile_details AND
NOT
(
ps.hide_for_unauthenticated AND
@requester::text = ''
) AND
NOT wl.hidden
)
THEN TRUE
ELSE FALSE
END AS access_allowed
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
WHERE
w.guid = (@guid::text)::uuid AND
w.deleted IS FALSE;
--: }}} --: }}}

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),
@@ -120,3 +121,63 @@ CREATE TABLE IF NOT EXISTS "wishes" (
fulfilled_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, fulfilled_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE
); );
CREATE OR REPLACE FUNCTION get_profile(requester_user_id BIGINT, target_profile_id BIGINT)
RETURNS JSONB AS $$
DECLARE
profile_record profiles%ROWTYPE;
settings_record profile_settings%ROWTYPE;
is_owner BOOLEAN;
is_banned BOOLEAN;
is_deleted BOOLEAN;
BEGIN
-- Check if target user exists and is not deleted/banned
SELECT p.*, u.deleted INTO profile_record
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.id = target_profile_id;
IF NOT FOUND THEN
RETURN NULL; -- Or raise an exception for "not found"
END IF;
is_deleted := profile_record.deleted; -- From users table
IF is_deleted THEN
RETURN NULL;
END IF;
-- Check if requester is banned (simplified; expand as needed)
SELECT EXISTS(SELECT 1 FROM banned_users WHERE user_id = requester_user_id AND pardoned = FALSE AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)) INTO is_banned;
IF is_banned THEN
RAISE EXCEPTION 'Requester is banned';
END IF;
-- Determine ownership
is_owner := (profile_record.user_id = requester_user_id);
-- Fetch settings
SELECT * INTO settings_record FROM profile_settings WHERE profile_id = target_profile_id;
-- Apply privacy: Hide for unauthenticated or based on settings
IF requester_user_id IS NULL AND settings_record.hide_for_unauthenticated THEN -- NULL requester means unauth
RETURN NULL;
END IF;
IF NOT is_owner AND settings_record.hide_profile_details THEN
RETURN NULL; -- Or return minimal public data
END IF;
-- Sanitize fields based on settings
IF NOT is_owner AND settings_record.hide_birthday THEN
profile_record.birthday := NULL;
END IF;
-- Add more field-level masking here (e.g., bio, avatar_url)
-- Return as JSONB for easy app consumption
RETURN row_to_json(profile_record)::JSONB;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Access denied: %', SQLERRM;
RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;