database schema adjustments to server/realm/scene
|
|
@ -17,7 +17,7 @@ pub async fn get_active_avatar<'e>(
|
||||||
let avatar = sqlx::query_as::<_, ActiveAvatar>(
|
let avatar = sqlx::query_as::<_, ActiveAvatar>(
|
||||||
r#"
|
r#"
|
||||||
SELECT user_id, realm_id, avatar_id, current_emotion, updated_at
|
SELECT user_id, realm_id, avatar_id, current_emotion, updated_at
|
||||||
FROM props.active_avatars
|
FROM auth.active_avatars
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -60,23 +60,23 @@ pub async fn set_emotion<'e>(
|
||||||
let query = format!(
|
let query = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH updated AS (
|
WITH updated AS (
|
||||||
UPDATE props.active_avatars
|
UPDATE auth.active_avatars
|
||||||
SET current_emotion = $3, updated_at = now()
|
SET current_emotion = $3, updated_at = now()
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
RETURNING avatar_id
|
RETURNING avatar_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_0) as p0,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_0) as p0,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_1) as p1,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_1) as p1,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_2) as p2,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_2) as p2,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_3) as p3,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_3) as p3,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_4) as p4,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_4) as p4,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_5) as p5,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_5) as p5,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_6) as p6,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_6) as p6,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_7) as p7,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_7) as p7,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_8) as p8
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_8) as p8
|
||||||
FROM updated u
|
FROM updated u
|
||||||
JOIN props.avatars a ON a.id = u.avatar_id
|
JOIN auth.avatars a ON a.id = u.avatar_id
|
||||||
"#,
|
"#,
|
||||||
prefix = emotion_prefix
|
prefix = emotion_prefix
|
||||||
);
|
);
|
||||||
|
|
@ -128,76 +128,76 @@ pub async fn get_emotion_availability<'e>(
|
||||||
(a.e_neutral_0 IS NOT NULL OR a.e_neutral_1 IS NOT NULL OR a.e_neutral_2 IS NOT NULL OR
|
(a.e_neutral_0 IS NOT NULL OR a.e_neutral_1 IS NOT NULL OR a.e_neutral_2 IS NOT NULL OR
|
||||||
a.e_neutral_3 IS NOT NULL OR a.e_neutral_4 IS NOT NULL OR a.e_neutral_5 IS NOT NULL OR
|
a.e_neutral_3 IS NOT NULL OR a.e_neutral_4 IS NOT NULL OR a.e_neutral_5 IS NOT NULL OR
|
||||||
a.e_neutral_6 IS NOT NULL OR a.e_neutral_7 IS NOT NULL OR a.e_neutral_8 IS NOT NULL) as avail_0,
|
a.e_neutral_6 IS NOT NULL OR a.e_neutral_7 IS NOT NULL OR a.e_neutral_8 IS NOT NULL) as avail_0,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_neutral_4) as preview_0,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_neutral_4) as preview_0,
|
||||||
|
|
||||||
-- Happy (1)
|
-- Happy (1)
|
||||||
(a.e_happy_0 IS NOT NULL OR a.e_happy_1 IS NOT NULL OR a.e_happy_2 IS NOT NULL OR
|
(a.e_happy_0 IS NOT NULL OR a.e_happy_1 IS NOT NULL OR a.e_happy_2 IS NOT NULL OR
|
||||||
a.e_happy_3 IS NOT NULL OR a.e_happy_4 IS NOT NULL OR a.e_happy_5 IS NOT NULL OR
|
a.e_happy_3 IS NOT NULL OR a.e_happy_4 IS NOT NULL OR a.e_happy_5 IS NOT NULL OR
|
||||||
a.e_happy_6 IS NOT NULL OR a.e_happy_7 IS NOT NULL OR a.e_happy_8 IS NOT NULL) as avail_1,
|
a.e_happy_6 IS NOT NULL OR a.e_happy_7 IS NOT NULL OR a.e_happy_8 IS NOT NULL) as avail_1,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_happy_4) as preview_1,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_happy_4) as preview_1,
|
||||||
|
|
||||||
-- Sad (2)
|
-- Sad (2)
|
||||||
(a.e_sad_0 IS NOT NULL OR a.e_sad_1 IS NOT NULL OR a.e_sad_2 IS NOT NULL OR
|
(a.e_sad_0 IS NOT NULL OR a.e_sad_1 IS NOT NULL OR a.e_sad_2 IS NOT NULL OR
|
||||||
a.e_sad_3 IS NOT NULL OR a.e_sad_4 IS NOT NULL OR a.e_sad_5 IS NOT NULL OR
|
a.e_sad_3 IS NOT NULL OR a.e_sad_4 IS NOT NULL OR a.e_sad_5 IS NOT NULL OR
|
||||||
a.e_sad_6 IS NOT NULL OR a.e_sad_7 IS NOT NULL OR a.e_sad_8 IS NOT NULL) as avail_2,
|
a.e_sad_6 IS NOT NULL OR a.e_sad_7 IS NOT NULL OR a.e_sad_8 IS NOT NULL) as avail_2,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sad_4) as preview_2,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_sad_4) as preview_2,
|
||||||
|
|
||||||
-- Angry (3)
|
-- Angry (3)
|
||||||
(a.e_angry_0 IS NOT NULL OR a.e_angry_1 IS NOT NULL OR a.e_angry_2 IS NOT NULL OR
|
(a.e_angry_0 IS NOT NULL OR a.e_angry_1 IS NOT NULL OR a.e_angry_2 IS NOT NULL OR
|
||||||
a.e_angry_3 IS NOT NULL OR a.e_angry_4 IS NOT NULL OR a.e_angry_5 IS NOT NULL OR
|
a.e_angry_3 IS NOT NULL OR a.e_angry_4 IS NOT NULL OR a.e_angry_5 IS NOT NULL OR
|
||||||
a.e_angry_6 IS NOT NULL OR a.e_angry_7 IS NOT NULL OR a.e_angry_8 IS NOT NULL) as avail_3,
|
a.e_angry_6 IS NOT NULL OR a.e_angry_7 IS NOT NULL OR a.e_angry_8 IS NOT NULL) as avail_3,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_angry_4) as preview_3,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_angry_4) as preview_3,
|
||||||
|
|
||||||
-- Surprised (4)
|
-- Surprised (4)
|
||||||
(a.e_surprised_0 IS NOT NULL OR a.e_surprised_1 IS NOT NULL OR a.e_surprised_2 IS NOT NULL OR
|
(a.e_surprised_0 IS NOT NULL OR a.e_surprised_1 IS NOT NULL OR a.e_surprised_2 IS NOT NULL OR
|
||||||
a.e_surprised_3 IS NOT NULL OR a.e_surprised_4 IS NOT NULL OR a.e_surprised_5 IS NOT NULL OR
|
a.e_surprised_3 IS NOT NULL OR a.e_surprised_4 IS NOT NULL OR a.e_surprised_5 IS NOT NULL OR
|
||||||
a.e_surprised_6 IS NOT NULL OR a.e_surprised_7 IS NOT NULL OR a.e_surprised_8 IS NOT NULL) as avail_4,
|
a.e_surprised_6 IS NOT NULL OR a.e_surprised_7 IS NOT NULL OR a.e_surprised_8 IS NOT NULL) as avail_4,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_surprised_4) as preview_4,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_surprised_4) as preview_4,
|
||||||
|
|
||||||
-- Thinking (5)
|
-- Thinking (5)
|
||||||
(a.e_thinking_0 IS NOT NULL OR a.e_thinking_1 IS NOT NULL OR a.e_thinking_2 IS NOT NULL OR
|
(a.e_thinking_0 IS NOT NULL OR a.e_thinking_1 IS NOT NULL OR a.e_thinking_2 IS NOT NULL OR
|
||||||
a.e_thinking_3 IS NOT NULL OR a.e_thinking_4 IS NOT NULL OR a.e_thinking_5 IS NOT NULL OR
|
a.e_thinking_3 IS NOT NULL OR a.e_thinking_4 IS NOT NULL OR a.e_thinking_5 IS NOT NULL OR
|
||||||
a.e_thinking_6 IS NOT NULL OR a.e_thinking_7 IS NOT NULL OR a.e_thinking_8 IS NOT NULL) as avail_5,
|
a.e_thinking_6 IS NOT NULL OR a.e_thinking_7 IS NOT NULL OR a.e_thinking_8 IS NOT NULL) as avail_5,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_thinking_4) as preview_5,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_thinking_4) as preview_5,
|
||||||
|
|
||||||
-- Laughing (6)
|
-- Laughing (6)
|
||||||
(a.e_laughing_0 IS NOT NULL OR a.e_laughing_1 IS NOT NULL OR a.e_laughing_2 IS NOT NULL OR
|
(a.e_laughing_0 IS NOT NULL OR a.e_laughing_1 IS NOT NULL OR a.e_laughing_2 IS NOT NULL OR
|
||||||
a.e_laughing_3 IS NOT NULL OR a.e_laughing_4 IS NOT NULL OR a.e_laughing_5 IS NOT NULL OR
|
a.e_laughing_3 IS NOT NULL OR a.e_laughing_4 IS NOT NULL OR a.e_laughing_5 IS NOT NULL OR
|
||||||
a.e_laughing_6 IS NOT NULL OR a.e_laughing_7 IS NOT NULL OR a.e_laughing_8 IS NOT NULL) as avail_6,
|
a.e_laughing_6 IS NOT NULL OR a.e_laughing_7 IS NOT NULL OR a.e_laughing_8 IS NOT NULL) as avail_6,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_laughing_4) as preview_6,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_laughing_4) as preview_6,
|
||||||
|
|
||||||
-- Crying (7)
|
-- Crying (7)
|
||||||
(a.e_crying_0 IS NOT NULL OR a.e_crying_1 IS NOT NULL OR a.e_crying_2 IS NOT NULL OR
|
(a.e_crying_0 IS NOT NULL OR a.e_crying_1 IS NOT NULL OR a.e_crying_2 IS NOT NULL OR
|
||||||
a.e_crying_3 IS NOT NULL OR a.e_crying_4 IS NOT NULL OR a.e_crying_5 IS NOT NULL OR
|
a.e_crying_3 IS NOT NULL OR a.e_crying_4 IS NOT NULL OR a.e_crying_5 IS NOT NULL OR
|
||||||
a.e_crying_6 IS NOT NULL OR a.e_crying_7 IS NOT NULL OR a.e_crying_8 IS NOT NULL) as avail_7,
|
a.e_crying_6 IS NOT NULL OR a.e_crying_7 IS NOT NULL OR a.e_crying_8 IS NOT NULL) as avail_7,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_crying_4) as preview_7,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_crying_4) as preview_7,
|
||||||
|
|
||||||
-- Love (8)
|
-- Love (8)
|
||||||
(a.e_love_0 IS NOT NULL OR a.e_love_1 IS NOT NULL OR a.e_love_2 IS NOT NULL OR
|
(a.e_love_0 IS NOT NULL OR a.e_love_1 IS NOT NULL OR a.e_love_2 IS NOT NULL OR
|
||||||
a.e_love_3 IS NOT NULL OR a.e_love_4 IS NOT NULL OR a.e_love_5 IS NOT NULL OR
|
a.e_love_3 IS NOT NULL OR a.e_love_4 IS NOT NULL OR a.e_love_5 IS NOT NULL OR
|
||||||
a.e_love_6 IS NOT NULL OR a.e_love_7 IS NOT NULL OR a.e_love_8 IS NOT NULL) as avail_8,
|
a.e_love_6 IS NOT NULL OR a.e_love_7 IS NOT NULL OR a.e_love_8 IS NOT NULL) as avail_8,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_love_4) as preview_8,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_love_4) as preview_8,
|
||||||
|
|
||||||
-- Confused (9)
|
-- Confused (9)
|
||||||
(a.e_confused_0 IS NOT NULL OR a.e_confused_1 IS NOT NULL OR a.e_confused_2 IS NOT NULL OR
|
(a.e_confused_0 IS NOT NULL OR a.e_confused_1 IS NOT NULL OR a.e_confused_2 IS NOT NULL OR
|
||||||
a.e_confused_3 IS NOT NULL OR a.e_confused_4 IS NOT NULL OR a.e_confused_5 IS NOT NULL OR
|
a.e_confused_3 IS NOT NULL OR a.e_confused_4 IS NOT NULL OR a.e_confused_5 IS NOT NULL OR
|
||||||
a.e_confused_6 IS NOT NULL OR a.e_confused_7 IS NOT NULL OR a.e_confused_8 IS NOT NULL) as avail_9,
|
a.e_confused_6 IS NOT NULL OR a.e_confused_7 IS NOT NULL OR a.e_confused_8 IS NOT NULL) as avail_9,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_confused_4) as preview_9,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_confused_4) as preview_9,
|
||||||
|
|
||||||
-- Sleeping (10)
|
-- Sleeping (10)
|
||||||
(a.e_sleeping_0 IS NOT NULL OR a.e_sleeping_1 IS NOT NULL OR a.e_sleeping_2 IS NOT NULL OR
|
(a.e_sleeping_0 IS NOT NULL OR a.e_sleeping_1 IS NOT NULL OR a.e_sleeping_2 IS NOT NULL OR
|
||||||
a.e_sleeping_3 IS NOT NULL OR a.e_sleeping_4 IS NOT NULL OR a.e_sleeping_5 IS NOT NULL OR
|
a.e_sleeping_3 IS NOT NULL OR a.e_sleeping_4 IS NOT NULL OR a.e_sleeping_5 IS NOT NULL OR
|
||||||
a.e_sleeping_6 IS NOT NULL OR a.e_sleeping_7 IS NOT NULL OR a.e_sleeping_8 IS NOT NULL) as avail_10,
|
a.e_sleeping_6 IS NOT NULL OR a.e_sleeping_7 IS NOT NULL OR a.e_sleeping_8 IS NOT NULL) as avail_10,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sleeping_4) as preview_10,
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_sleeping_4) as preview_10,
|
||||||
|
|
||||||
-- Wink (11)
|
-- Wink (11)
|
||||||
(a.e_wink_0 IS NOT NULL OR a.e_wink_1 IS NOT NULL OR a.e_wink_2 IS NOT NULL OR
|
(a.e_wink_0 IS NOT NULL OR a.e_wink_1 IS NOT NULL OR a.e_wink_2 IS NOT NULL OR
|
||||||
a.e_wink_3 IS NOT NULL OR a.e_wink_4 IS NOT NULL OR a.e_wink_5 IS NOT NULL OR
|
a.e_wink_3 IS NOT NULL OR a.e_wink_4 IS NOT NULL OR a.e_wink_5 IS NOT NULL OR
|
||||||
a.e_wink_6 IS NOT NULL OR a.e_wink_7 IS NOT NULL OR a.e_wink_8 IS NOT NULL) as avail_11,
|
a.e_wink_6 IS NOT NULL OR a.e_wink_7 IS NOT NULL OR a.e_wink_8 IS NOT NULL) as avail_11,
|
||||||
(SELECT prop_asset_path FROM props.inventory WHERE id = a.e_wink_4) as preview_11
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_wink_4) as preview_11
|
||||||
|
|
||||||
FROM props.active_avatars aa
|
FROM auth.active_avatars aa
|
||||||
JOIN props.avatars a ON aa.avatar_id = a.id
|
JOIN auth.avatars a ON aa.avatar_id = a.id
|
||||||
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -294,8 +294,8 @@ pub async fn get_avatar_with_paths(
|
||||||
SELECT
|
SELECT
|
||||||
a.*,
|
a.*,
|
||||||
aa.current_emotion
|
aa.current_emotion
|
||||||
FROM props.active_avatars aa
|
FROM auth.active_avatars aa
|
||||||
JOIN props.avatars a ON aa.avatar_id = a.id
|
JOIN auth.avatars a ON aa.avatar_id = a.id
|
||||||
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -395,7 +395,7 @@ pub async fn get_avatar_with_paths(
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, (Uuid, String)>(
|
sqlx::query_as::<_, (Uuid, String)>(
|
||||||
"SELECT id, prop_asset_path FROM props.inventory WHERE id = ANY($1)",
|
"SELECT id, prop_asset_path FROM auth.inventory WHERE id = ANY($1)",
|
||||||
)
|
)
|
||||||
.bind(&uuids)
|
.bind(&uuids)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|
@ -540,8 +540,8 @@ pub async fn get_avatar_with_paths_conn(
|
||||||
SELECT
|
SELECT
|
||||||
a.*,
|
a.*,
|
||||||
aa.current_emotion
|
aa.current_emotion
|
||||||
FROM props.active_avatars aa
|
FROM auth.active_avatars aa
|
||||||
JOIN props.avatars a ON aa.avatar_id = a.id
|
JOIN auth.avatars a ON aa.avatar_id = a.id
|
||||||
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -641,7 +641,7 @@ pub async fn get_avatar_with_paths_conn(
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, (Uuid, String)>(
|
sqlx::query_as::<_, (Uuid, String)>(
|
||||||
"SELECT id, prop_asset_path FROM props.inventory WHERE id = ANY($1)",
|
"SELECT id, prop_asset_path FROM auth.inventory WHERE id = ANY($1)",
|
||||||
)
|
)
|
||||||
.bind(&uuids)
|
.bind(&uuids)
|
||||||
.fetch_all(&mut *conn)
|
.fetch_all(&mut *conn)
|
||||||
|
|
@ -941,7 +941,7 @@ pub async fn set_emotion_simple<'e>(
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE props.active_avatars
|
UPDATE auth.active_avatars
|
||||||
SET current_emotion = $3, updated_at = now()
|
SET current_emotion = $3, updated_at = now()
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
"#,
|
"#,
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ pub async fn join_channel<'e>(
|
||||||
// Note: channel_id is actually scene_id in this system
|
// Note: channel_id is actually scene_id in this system
|
||||||
let member = sqlx::query_as::<_, ChannelMember>(
|
let member = sqlx::query_as::<_, ChannelMember>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO realm.channel_members (channel_id, user_id, position)
|
INSERT INTO scene.instance_members (instance_id, user_id, position)
|
||||||
SELECT $1, $2, COALESCE(
|
SELECT $1, $2, COALESCE(
|
||||||
-- Try to restore last position if user was in the same scene
|
-- Try to restore last position if user was in the same scene
|
||||||
-- Note: channel_id = scene_id in this system
|
-- Note: instance_id = scene_id in this system
|
||||||
(SELECT m.last_position
|
(SELECT m.last_position
|
||||||
FROM realm.memberships m
|
FROM realm.memberships m
|
||||||
JOIN realm.scenes s ON s.id = $1
|
JOIN realm.scenes s ON s.id = $1
|
||||||
|
|
@ -33,11 +33,11 @@ pub async fn join_channel<'e>(
|
||||||
-- Default position
|
-- Default position
|
||||||
ST_SetSRID(ST_MakePoint(400, 300), 0)
|
ST_SetSRID(ST_MakePoint(400, 300), 0)
|
||||||
)
|
)
|
||||||
ON CONFLICT (channel_id, user_id) DO UPDATE
|
ON CONFLICT (instance_id, user_id) DO UPDATE
|
||||||
SET joined_at = now()
|
SET joined_at = now()
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
channel_id,
|
instance_id as channel_id,
|
||||||
user_id,
|
user_id,
|
||||||
guest_session_id,
|
guest_session_id,
|
||||||
ST_X(position) as position_x,
|
ST_X(position) as position_x,
|
||||||
|
|
@ -66,9 +66,9 @@ pub async fn ensure_active_avatar<'e>(
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO props.active_avatars (user_id, realm_id, avatar_id, current_emotion)
|
INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion)
|
||||||
SELECT $1, $2, id, 0
|
SELECT $1, $2, id, 0
|
||||||
FROM props.avatars
|
FROM auth.avatars
|
||||||
WHERE user_id = $1 AND slot_number = 0
|
WHERE user_id = $1 AND slot_number = 0
|
||||||
ON CONFLICT (user_id, realm_id) DO NOTHING
|
ON CONFLICT (user_id, realm_id) DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -95,10 +95,10 @@ pub async fn leave_channel<'e>(
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
WITH member_info AS (
|
WITH member_info AS (
|
||||||
SELECT cm.position, cm.channel_id as scene_id, s.realm_id
|
SELECT cm.position, cm.instance_id as scene_id, s.realm_id
|
||||||
FROM realm.channel_members cm
|
FROM scene.instance_members cm
|
||||||
JOIN realm.scenes s ON cm.channel_id = s.id
|
JOIN realm.scenes s ON cm.instance_id = s.id
|
||||||
WHERE cm.channel_id = $1 AND cm.user_id = $2
|
WHERE cm.instance_id = $1 AND cm.user_id = $2
|
||||||
),
|
),
|
||||||
save_position AS (
|
save_position AS (
|
||||||
UPDATE realm.memberships m
|
UPDATE realm.memberships m
|
||||||
|
|
@ -110,8 +110,8 @@ pub async fn leave_channel<'e>(
|
||||||
RETURNING m.user_id
|
RETURNING m.user_id
|
||||||
),
|
),
|
||||||
do_delete AS (
|
do_delete AS (
|
||||||
DELETE FROM realm.channel_members
|
DELETE FROM scene.instance_members
|
||||||
WHERE channel_id = $1 AND user_id = $2
|
WHERE instance_id = $1 AND user_id = $2
|
||||||
RETURNING user_id
|
RETURNING user_id
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) FROM save_position, do_delete
|
SELECT COUNT(*) FROM save_position, do_delete
|
||||||
|
|
@ -135,11 +135,11 @@ pub async fn update_position<'e>(
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE realm.channel_members
|
UPDATE scene.instance_members
|
||||||
SET position = ST_SetSRID(ST_MakePoint($3, $4), 0),
|
SET position = ST_SetSRID(ST_MakePoint($3, $4), 0),
|
||||||
last_moved_at = now(),
|
last_moved_at = now(),
|
||||||
is_moving = true
|
is_moving = true
|
||||||
WHERE channel_id = $1 AND user_id = $2
|
WHERE instance_id = $1 AND user_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
|
|
@ -166,7 +166,7 @@ pub async fn get_channel_members<'e>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
cm.id,
|
cm.id,
|
||||||
cm.channel_id,
|
cm.instance_id as channel_id,
|
||||||
cm.user_id,
|
cm.user_id,
|
||||||
cm.guest_session_id,
|
cm.guest_session_id,
|
||||||
COALESCE(u.display_name, gs.guest_name, 'Anonymous') as display_name,
|
COALESCE(u.display_name, gs.guest_name, 'Anonymous') as display_name,
|
||||||
|
|
@ -177,11 +177,11 @@ pub async fn get_channel_members<'e>(
|
||||||
cm.is_afk,
|
cm.is_afk,
|
||||||
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||||
cm.joined_at
|
cm.joined_at
|
||||||
FROM realm.channel_members cm
|
FROM scene.instance_members cm
|
||||||
LEFT JOIN auth.users u ON cm.user_id = u.id
|
LEFT JOIN auth.users u ON cm.user_id = u.id
|
||||||
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
|
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
|
||||||
LEFT JOIN props.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2
|
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2
|
||||||
WHERE cm.channel_id = $1
|
WHERE cm.instance_id = $1
|
||||||
ORDER BY cm.joined_at ASC
|
ORDER BY cm.joined_at ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -204,7 +204,7 @@ pub async fn get_channel_member<'e>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
cm.id,
|
cm.id,
|
||||||
cm.channel_id,
|
cm.instance_id as channel_id,
|
||||||
cm.user_id,
|
cm.user_id,
|
||||||
cm.guest_session_id,
|
cm.guest_session_id,
|
||||||
COALESCE(u.display_name, 'Anonymous') as display_name,
|
COALESCE(u.display_name, 'Anonymous') as display_name,
|
||||||
|
|
@ -215,10 +215,10 @@ pub async fn get_channel_member<'e>(
|
||||||
cm.is_afk,
|
cm.is_afk,
|
||||||
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||||
cm.joined_at
|
cm.joined_at
|
||||||
FROM realm.channel_members cm
|
FROM scene.instance_members cm
|
||||||
LEFT JOIN auth.users u ON cm.user_id = u.id
|
LEFT JOIN auth.users u ON cm.user_id = u.id
|
||||||
LEFT JOIN props.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3
|
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3
|
||||||
WHERE cm.channel_id = $1 AND cm.user_id = $2
|
WHERE cm.instance_id = $1 AND cm.user_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
|
|
@ -238,9 +238,9 @@ pub async fn set_stopped<'e>(
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE realm.channel_members
|
UPDATE scene.instance_members
|
||||||
SET is_moving = false
|
SET is_moving = false
|
||||||
WHERE channel_id = $1 AND user_id = $2
|
WHERE instance_id = $1 AND user_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
|
|
@ -260,9 +260,9 @@ pub async fn set_afk<'e>(
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE realm.channel_members
|
UPDATE scene.instance_members
|
||||||
SET is_afk = $3
|
SET is_afk = $3
|
||||||
WHERE channel_id = $1 AND user_id = $2
|
WHERE instance_id = $1 AND user_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,12 @@ pub async fn get_channel_info<'e>(
|
||||||
let info = sqlx::query_as::<_, ChannelInfo>(
|
let info = sqlx::query_as::<_, ChannelInfo>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
c.id,
|
i.id,
|
||||||
c.scene_id,
|
i.scene_id,
|
||||||
s.realm_id
|
s.realm_id
|
||||||
FROM realm.channels c
|
FROM scene.instances i
|
||||||
JOIN realm.scenes s ON s.id = c.scene_id
|
JOIN realm.scenes s ON s.id = i.scene_id
|
||||||
WHERE c.id = $1
|
WHERE i.id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ pub async fn list_user_inventory<'e>(
|
||||||
is_droppable,
|
is_droppable,
|
||||||
origin,
|
origin,
|
||||||
acquired_at
|
acquired_at
|
||||||
FROM props.inventory
|
FROM auth.inventory
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY acquired_at DESC
|
ORDER BY acquired_at DESC
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -48,11 +48,11 @@ pub async fn drop_inventory_item<'e>(
|
||||||
r#"
|
r#"
|
||||||
WITH item_info AS (
|
WITH item_info AS (
|
||||||
SELECT id, is_droppable
|
SELECT id, is_droppable
|
||||||
FROM props.inventory
|
FROM auth.inventory
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
),
|
),
|
||||||
deleted AS (
|
deleted AS (
|
||||||
DELETE FROM props.inventory
|
DELETE FROM auth.inventory
|
||||||
WHERE id = $1 AND user_id = $2 AND is_droppable = true
|
WHERE id = $1 AND user_id = $2 AND is_droppable = true
|
||||||
RETURNING id
|
RETURNING id
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ pub async fn list_channel_loose_props<'e>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
lp.id,
|
lp.id,
|
||||||
lp.channel_id,
|
lp.instance_id as channel_id,
|
||||||
lp.server_prop_id,
|
lp.server_prop_id,
|
||||||
lp.realm_prop_id,
|
lp.realm_prop_id,
|
||||||
ST_X(lp.position) as position_x,
|
ST_X(lp.position) as position_x,
|
||||||
|
|
@ -27,10 +27,10 @@ pub async fn list_channel_loose_props<'e>(
|
||||||
lp.created_at,
|
lp.created_at,
|
||||||
COALESCE(sp.name, rp.name) as prop_name,
|
COALESCE(sp.name, rp.name) as prop_name,
|
||||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path
|
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path
|
||||||
FROM props.loose_props lp
|
FROM scene.loose_props lp
|
||||||
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
||||||
LEFT JOIN props.realm_props rp ON lp.realm_prop_id = rp.id
|
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
|
||||||
WHERE lp.channel_id = $1
|
WHERE lp.instance_id = $1
|
||||||
AND (lp.expires_at IS NULL OR lp.expires_at > now())
|
AND (lp.expires_at IS NULL OR lp.expires_at > now())
|
||||||
ORDER BY lp.created_at ASC
|
ORDER BY lp.created_at ASC
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -61,17 +61,17 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
r#"
|
r#"
|
||||||
WITH item_info AS (
|
WITH item_info AS (
|
||||||
SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
||||||
FROM props.inventory
|
FROM auth.inventory
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
),
|
),
|
||||||
deleted_item AS (
|
deleted_item AS (
|
||||||
DELETE FROM props.inventory
|
DELETE FROM auth.inventory
|
||||||
WHERE id = $1 AND user_id = $2 AND is_droppable = true
|
WHERE id = $1 AND user_id = $2 AND is_droppable = true
|
||||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
||||||
),
|
),
|
||||||
inserted_prop AS (
|
inserted_prop AS (
|
||||||
INSERT INTO props.loose_props (
|
INSERT INTO scene.loose_props (
|
||||||
channel_id,
|
instance_id,
|
||||||
server_prop_id,
|
server_prop_id,
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
position,
|
position,
|
||||||
|
|
@ -88,7 +88,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
FROM deleted_item di
|
FROM deleted_item di
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
channel_id,
|
instance_id as channel_id,
|
||||||
server_prop_id,
|
server_prop_id,
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
ST_X(position) as position_x,
|
ST_X(position) as position_x,
|
||||||
|
|
@ -202,7 +202,7 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
let item = sqlx::query_as::<_, InventoryItem>(
|
let item = sqlx::query_as::<_, InventoryItem>(
|
||||||
r#"
|
r#"
|
||||||
WITH deleted_prop AS (
|
WITH deleted_prop AS (
|
||||||
DELETE FROM props.loose_props
|
DELETE FROM scene.loose_props
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND (expires_at IS NULL OR expires_at > now())
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
RETURNING id, server_prop_id, realm_prop_id
|
RETURNING id, server_prop_id, realm_prop_id
|
||||||
|
|
@ -219,10 +219,10 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
dp.realm_prop_id
|
dp.realm_prop_id
|
||||||
FROM deleted_prop dp
|
FROM deleted_prop dp
|
||||||
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
|
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
|
||||||
LEFT JOIN props.realm_props rp ON dp.realm_prop_id = rp.id
|
LEFT JOIN realm.props rp ON dp.realm_prop_id = rp.id
|
||||||
),
|
),
|
||||||
inserted_item AS (
|
inserted_item AS (
|
||||||
INSERT INTO props.inventory (
|
INSERT INTO auth.inventory (
|
||||||
user_id,
|
user_id,
|
||||||
server_prop_id,
|
server_prop_id,
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
|
|
@ -243,7 +243,7 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
si.prop_name,
|
si.prop_name,
|
||||||
si.prop_asset_path,
|
si.prop_asset_path,
|
||||||
si.layer,
|
si.layer,
|
||||||
'server_library'::props.prop_origin,
|
'server_library'::server.prop_origin,
|
||||||
COALESCE(si.is_transferable, true),
|
COALESCE(si.is_transferable, true),
|
||||||
COALESCE(si.is_portable, true),
|
COALESCE(si.is_portable, true),
|
||||||
COALESCE(si.is_droppable, true),
|
COALESCE(si.is_droppable, true),
|
||||||
|
|
@ -260,7 +260,7 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
ii.is_transferable,
|
ii.is_transferable,
|
||||||
ii.is_portable,
|
ii.is_portable,
|
||||||
ii.is_droppable,
|
ii.is_droppable,
|
||||||
'server_library'::props.prop_origin as origin,
|
'server_library'::server.prop_origin as origin,
|
||||||
ii.acquired_at
|
ii.acquired_at
|
||||||
FROM inserted_item ii
|
FROM inserted_item ii
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -280,7 +280,7 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result<u64, AppError> {
|
pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result<u64, AppError> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
DELETE FROM props.loose_props
|
DELETE FROM scene.loose_props
|
||||||
WHERE expires_at IS NOT NULL AND expires_at <= now()
|
WHERE expires_at IS NOT NULL AND expires_at <= now()
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ pub async fn create_server_prop<'e>(
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6::props.avatar_layer, $7::props.emotion_state, $8,
|
$6::server.avatar_layer, $7::server.emotion_state, $8,
|
||||||
$9
|
$9
|
||||||
)
|
)
|
||||||
RETURNING
|
RETURNING
|
||||||
|
|
@ -207,7 +207,7 @@ pub async fn upsert_server_prop<'e>(
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6::props.avatar_layer, $7::props.emotion_state, $8,
|
$6::server.avatar_layer, $7::server.emotion_state, $8,
|
||||||
$9
|
$9
|
||||||
)
|
)
|
||||||
ON CONFLICT (slug) DO UPDATE SET
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ pub async fn get_scene_by_id<'e>(
|
||||||
s.updated_at,
|
s.updated_at,
|
||||||
c.id as default_channel_id
|
c.id as default_channel_id
|
||||||
FROM realm.scenes s
|
FROM realm.scenes s
|
||||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
LEFT JOIN scene.instances c ON c.scene_id = s.id AND c.instance_type = 'public'
|
||||||
WHERE s.id = $1
|
WHERE s.id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -98,7 +98,7 @@ pub async fn get_scene_by_slug<'e>(
|
||||||
s.updated_at,
|
s.updated_at,
|
||||||
c.id as default_channel_id
|
c.id as default_channel_id
|
||||||
FROM realm.scenes s
|
FROM realm.scenes s
|
||||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
LEFT JOIN scene.instances c ON c.scene_id = s.id AND c.instance_type = 'public'
|
||||||
WHERE s.realm_id = $1 AND s.slug = $2
|
WHERE s.realm_id = $1 AND s.slug = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -316,7 +316,7 @@ pub async fn update_scene<'e>(
|
||||||
s.ambient_audio_id, s.ambient_volume, s.sort_order, s.is_entry_point,
|
s.ambient_audio_id, s.ambient_volume, s.sort_order, s.is_entry_point,
|
||||||
s.is_hidden, s.created_at, s.updated_at, c.id as default_channel_id
|
s.is_hidden, s.created_at, s.updated_at, c.id as default_channel_id
|
||||||
FROM realm.scenes s
|
FROM realm.scenes s
|
||||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
LEFT JOIN scene.instances c ON c.scene_id = s.id AND c.instance_type = 'public'
|
||||||
WHERE s.id = $1"#.to_string()
|
WHERE s.id = $1"#.to_string()
|
||||||
} else {
|
} else {
|
||||||
set_clauses.push("updated_at = now()".to_string());
|
set_clauses.push("updated_at = now()".to_string());
|
||||||
|
|
@ -331,7 +331,7 @@ pub async fn update_scene<'e>(
|
||||||
)
|
)
|
||||||
SELECT u.*, c.id as default_channel_id
|
SELECT u.*, c.id as default_channel_id
|
||||||
FROM updated u
|
FROM updated u
|
||||||
LEFT JOIN realm.channels c ON c.scene_id = u.id AND c.channel_type = 'public'"#,
|
LEFT JOIN scene.instances c ON c.scene_id = u.id AND c.instance_type = 'public'"#,
|
||||||
set_clauses.join(", ")
|
set_clauses.join(", ")
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -442,7 +442,7 @@ pub async fn get_entry_scene_for_realm<'e>(
|
||||||
s.updated_at,
|
s.updated_at,
|
||||||
c.id as default_channel_id
|
c.id as default_channel_id
|
||||||
FROM realm.scenes s
|
FROM realm.scenes s
|
||||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
LEFT JOIN scene.instances c ON c.scene_id = s.id AND c.instance_type = 'public'
|
||||||
WHERE s.realm_id = $1 AND s.is_hidden = false
|
WHERE s.realm_id = $1 AND s.is_hidden = false
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN s.id = $2 THEN 0 ELSE 1 END,
|
CASE WHEN s.id = $2 THEN 0 ELSE 1 END,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ pub async fn list_spots_for_scene<'e>(
|
||||||
sort_order,
|
sort_order,
|
||||||
is_visible,
|
is_visible,
|
||||||
is_active
|
is_active
|
||||||
FROM realm.spots
|
FROM scene.spots
|
||||||
WHERE scene_id = $1
|
WHERE scene_id = $1
|
||||||
ORDER BY sort_order ASC, name ASC NULLS LAST
|
ORDER BY sort_order ASC, name ASC NULLS LAST
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -56,7 +56,7 @@ pub async fn get_spot_by_id<'e>(
|
||||||
is_active,
|
is_active,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM realm.spots
|
FROM scene.spots
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -90,7 +90,7 @@ pub async fn get_spot_by_slug<'e>(
|
||||||
is_active,
|
is_active,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM realm.spots
|
FROM scene.spots
|
||||||
WHERE scene_id = $1 AND slug = $2
|
WHERE scene_id = $1 AND slug = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -109,7 +109,7 @@ pub async fn is_spot_slug_available<'e>(
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let exists: (bool,) =
|
let exists: (bool,) =
|
||||||
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM realm.spots WHERE scene_id = $1 AND slug = $2)"#)
|
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM scene.spots WHERE scene_id = $1 AND slug = $2)"#)
|
||||||
.bind(scene_id)
|
.bind(scene_id)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.fetch_one(executor)
|
.fetch_one(executor)
|
||||||
|
|
@ -131,7 +131,7 @@ pub async fn create_spot<'e>(
|
||||||
|
|
||||||
let spot = sqlx::query_as::<_, Spot>(
|
let spot = sqlx::query_as::<_, Spot>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO realm.spots (
|
INSERT INTO scene.spots (
|
||||||
scene_id, name, slug,
|
scene_id, name, slug,
|
||||||
region, spot_type,
|
region, spot_type,
|
||||||
destination_scene_id, destination_position,
|
destination_scene_id, destination_position,
|
||||||
|
|
@ -139,7 +139,7 @@ pub async fn create_spot<'e>(
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3,
|
$1, $2, $3,
|
||||||
ST_GeomFromText($4, 0), $5::realm.spot_type,
|
ST_GeomFromText($4, 0), $5::scene.spot_type,
|
||||||
$6, CASE WHEN $7 IS NOT NULL THEN ST_GeomFromText($7, 0) ELSE NULL END,
|
$6, CASE WHEN $7 IS NOT NULL THEN ST_GeomFromText($7, 0) ELSE NULL END,
|
||||||
$8, $9, $10
|
$8, $9, $10
|
||||||
)
|
)
|
||||||
|
|
@ -199,7 +199,7 @@ pub async fn update_spot<'e>(
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
}
|
}
|
||||||
if req.spot_type.is_some() {
|
if req.spot_type.is_some() {
|
||||||
set_clauses.push(format!("spot_type = ${}::realm.spot_type", param_idx));
|
set_clauses.push(format!("spot_type = ${}::scene.spot_type", param_idx));
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
}
|
}
|
||||||
if req.destination_scene_id.is_some() {
|
if req.destination_scene_id.is_some() {
|
||||||
|
|
@ -236,11 +236,11 @@ pub async fn update_spot<'e>(
|
||||||
ST_AsText(destination_position) as destination_position_wkt,
|
ST_AsText(destination_position) as destination_position_wkt,
|
||||||
current_state, sort_order, is_visible, is_active,
|
current_state, sort_order, is_visible, is_active,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM realm.spots WHERE id = $1"#.to_string()
|
FROM scene.spots WHERE id = $1"#.to_string()
|
||||||
} else {
|
} else {
|
||||||
set_clauses.push("updated_at = now()".to_string());
|
set_clauses.push("updated_at = now()".to_string());
|
||||||
format!(
|
format!(
|
||||||
r#"UPDATE realm.spots SET {}
|
r#"UPDATE scene.spots SET {}
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, scene_id, name, slug, ST_AsText(region) as region_wkt,
|
RETURNING id, scene_id, name, slug, ST_AsText(region) as region_wkt,
|
||||||
spot_type, destination_scene_id,
|
spot_type, destination_scene_id,
|
||||||
|
|
@ -297,7 +297,7 @@ pub async fn delete_spot<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
spot_id: Uuid,
|
spot_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let result = sqlx::query(r#"DELETE FROM realm.spots WHERE id = $1"#)
|
let result = sqlx::query(r#"DELETE FROM scene.spots WHERE id = $1"#)
|
||||||
.bind(spot_id)
|
.bind(spot_id)
|
||||||
.execute(executor)
|
.execute(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -315,7 +315,7 @@ pub async fn get_next_sort_order<'e>(
|
||||||
scene_id: Uuid,
|
scene_id: Uuid,
|
||||||
) -> Result<i32, AppError> {
|
) -> Result<i32, AppError> {
|
||||||
let result: (Option<i32>,) =
|
let result: (Option<i32>,) =
|
||||||
sqlx::query_as(r#"SELECT MAX(sort_order) FROM realm.spots WHERE scene_id = $1"#)
|
sqlx::query_as(r#"SELECT MAX(sort_order) FROM scene.spots WHERE scene_id = $1"#)
|
||||||
.bind(scene_id)
|
.bind(scene_id)
|
||||||
.fetch_one(executor)
|
.fetch_one(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
|
||||||
25
db/reinitialize_all_users.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- Reinitialize all users with current server props
|
||||||
|
-- Use: psql -d chattyness -f reinitialize_all_users.sql
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_user RECORD;
|
||||||
|
v_count INT := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR v_user IN SELECT id, username FROM auth.users
|
||||||
|
LOOP
|
||||||
|
-- Clear existing data
|
||||||
|
DELETE FROM props.active_avatars WHERE user_id = v_user.id;
|
||||||
|
DELETE FROM props.avatars WHERE user_id = v_user.id;
|
||||||
|
DELETE FROM props.inventory WHERE user_id = v_user.id;
|
||||||
|
|
||||||
|
-- Reinitialize with current server props
|
||||||
|
PERFORM auth.initialize_new_user(v_user.id);
|
||||||
|
|
||||||
|
v_count := v_count + 1;
|
||||||
|
RAISE NOTICE 'Reinitialized user: % (%)', v_user.username, v_user.id;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Total users reinitialized: %', v_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
59
db/reinitialize_user.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Reinitialize User with Default Props
|
||||||
|
|
||||||
|
When stock props or avatars are updated in the database, existing users may need to be reinitialized to receive the new defaults.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Find the user's ID:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, username FROM auth.users WHERE username = 'TARGET_USERNAME';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Clear existing data and reinitialize:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Clear existing props and avatars for the user
|
||||||
|
DELETE FROM props.active_avatars WHERE user_id = 'USER_UUID';
|
||||||
|
DELETE FROM props.avatars WHERE user_id = 'USER_UUID';
|
||||||
|
DELETE FROM props.inventory WHERE user_id = 'USER_UUID';
|
||||||
|
|
||||||
|
-- Reinitialize with current server props
|
||||||
|
SELECT auth.initialize_new_user('USER_UUID');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the results:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) as inventory_count FROM props.inventory WHERE user_id = 'USER_UUID';
|
||||||
|
SELECT id, name, slot_number FROM props.avatars WHERE user_id = 'USER_UUID';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Reinitialize ranosh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -d chattyness <<'EOF'
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DELETE FROM props.active_avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
|
||||||
|
DELETE FROM props.avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
|
||||||
|
DELETE FROM props.inventory WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
|
||||||
|
|
||||||
|
SELECT auth.initialize_new_user('57a12201-ea0f-4545-9ccc-c4e67ea7e2c4');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## What `initialize_new_user` Does
|
||||||
|
|
||||||
|
The `auth.initialize_new_user()` function:
|
||||||
|
|
||||||
|
1. Inserts all face-tagged server props into the user's inventory
|
||||||
|
2. Creates a default avatar (slot 0) with:
|
||||||
|
- Face prop in the skin layer (position 4, center)
|
||||||
|
- All emotion props mapped to their respective emotion slots
|
||||||
149
db/schema/000_init.sql
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
-- Chattyness Database Schema Initialization
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- This file drops all existing schema and recreates from scratch.
|
||||||
|
-- Load via: psql -d chattyness -f schema/000_init.sql
|
||||||
|
--
|
||||||
|
-- WARNING: This destroys all data!
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Drop Everything (CASCADE removes all dependent objects)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS audit CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS chat CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS scene CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS realm CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS auth CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS server CASCADE;
|
||||||
|
|
||||||
|
-- Drop public domains we create (not removed by schema drops)
|
||||||
|
DROP DOMAIN IF EXISTS public.scene_bounds CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.virtual_point CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.asset_path CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.url CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.hex_color CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.slug CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.display_name CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.nonempty_text CASCADE;
|
||||||
|
DROP DOMAIN IF EXISTS public.percentage CASCADE;
|
||||||
|
|
||||||
|
-- Drop all objects owned by application roles (required to avoid dependency errors)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Drop objects owned by chattyness_owner
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chattyness_owner') THEN
|
||||||
|
DROP OWNED BY chattyness_owner CASCADE;
|
||||||
|
END IF;
|
||||||
|
-- Drop objects owned by chattyness_app
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'chattyness_app') THEN
|
||||||
|
DROP OWNED BY chattyness_app CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Drop all application roles
|
||||||
|
DROP ROLE IF EXISTS chattyness_app;
|
||||||
|
DROP ROLE IF EXISTS chattyness_owner;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Extensions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "btree_gist";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "postgis";
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Schemas
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE SCHEMA server;
|
||||||
|
COMMENT ON SCHEMA server IS 'Server-wide configuration, global props, shared resources, and server-level moderation';
|
||||||
|
|
||||||
|
CREATE SCHEMA auth;
|
||||||
|
COMMENT ON SCHEMA auth IS 'User authentication, accounts, identity management, inventory, and avatars';
|
||||||
|
|
||||||
|
CREATE SCHEMA realm;
|
||||||
|
COMMENT ON SCHEMA realm IS 'Realms, scenes, memberships, realm props, and realm-level moderation';
|
||||||
|
|
||||||
|
CREATE SCHEMA scene;
|
||||||
|
COMMENT ON SCHEMA scene IS 'Scene instances, members, spots, loose props, and decorations';
|
||||||
|
|
||||||
|
CREATE SCHEMA chat;
|
||||||
|
COMMENT ON SCHEMA chat IS 'Messages, whispers, shouts, and reactions';
|
||||||
|
|
||||||
|
CREATE SCHEMA audit;
|
||||||
|
COMMENT ON SCHEMA audit IS 'Audit trails and activity logging';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Application Roles
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Application login role for normal operations (subject to RLS)
|
||||||
|
-- Password is set in load.sql
|
||||||
|
CREATE ROLE chattyness_app LOGIN;
|
||||||
|
COMMENT ON ROLE chattyness_app IS 'Application login role - subject to RLS, user context passed via session variables';
|
||||||
|
|
||||||
|
-- Owner role with full access (bypasses RLS)
|
||||||
|
-- Password is set in load.sql
|
||||||
|
CREATE ROLE chattyness_owner LOGIN BYPASSRLS;
|
||||||
|
COMMENT ON ROLE chattyness_owner IS 'Owner role - bypasses RLS for server management';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Grant Full Access to Owner Role
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Grant usage on all schemas
|
||||||
|
GRANT USAGE ON SCHEMA public TO chattyness_owner;
|
||||||
|
GRANT USAGE ON SCHEMA server TO chattyness_owner;
|
||||||
|
GRANT USAGE ON SCHEMA auth TO chattyness_owner;
|
||||||
|
GRANT USAGE ON SCHEMA realm TO chattyness_owner;
|
||||||
|
GRANT USAGE ON SCHEMA scene TO chattyness_owner;
|
||||||
|
GRANT USAGE ON SCHEMA chat TO chattyness_owner;
|
||||||
|
GRANT USAGE ON SCHEMA audit TO chattyness_owner;
|
||||||
|
|
||||||
|
-- Grant all privileges on all tables in each schema
|
||||||
|
-- These will apply to tables created later in the load sequence
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA server GRANT ALL ON TABLES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON TABLES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA realm GRANT ALL ON TABLES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA scene GRANT ALL ON TABLES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA chat GRANT ALL ON TABLES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA audit GRANT ALL ON TABLES TO chattyness_owner;
|
||||||
|
|
||||||
|
-- Grant all privileges on sequences (for identity columns)
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA server GRANT ALL ON SEQUENCES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT ALL ON SEQUENCES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA realm GRANT ALL ON SEQUENCES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA scene GRANT ALL ON SEQUENCES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA chat GRANT ALL ON SEQUENCES TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA audit GRANT ALL ON SEQUENCES TO chattyness_owner;
|
||||||
|
|
||||||
|
-- Grant execute on all functions
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA server GRANT EXECUTE ON FUNCTIONS TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA auth GRANT EXECUTE ON FUNCTIONS TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA realm GRANT EXECUTE ON FUNCTIONS TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA scene GRANT EXECUTE ON FUNCTIONS TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA chat GRANT EXECUTE ON FUNCTIONS TO chattyness_owner;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA audit GRANT EXECUTE ON FUNCTIONS TO chattyness_owner;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Schema Search Path
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('ALTER DATABASE %I SET search_path TO public, server, auth, realm, scene, chat, audit', current_database());
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
280
db/schema/functions/001_helpers.sql
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
-- Chattyness Helper Functions
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Utility functions and common operations
|
||||||
|
-- Load via: psql -f schema/functions/001_helpers.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Updated At Trigger Function
|
||||||
|
-- =============================================================================
|
||||||
|
-- Automatically updates updated_at column on row modification.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION public.update_updated_at_column() IS
|
||||||
|
'Trigger function to automatically update updated_at timestamp';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Session Context Functions
|
||||||
|
-- =============================================================================
|
||||||
|
-- Functions to get/set session context for RLS policies.
|
||||||
|
-- Application sets these variables when handling requests.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Set current user ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_current_user_id(user_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('app.current_user_id', user_id::TEXT, false);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Get current user ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.current_user_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Set current realm ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_current_realm_id(realm_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('app.current_realm_id', realm_id::TEXT, false);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Get current realm ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.current_realm_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN NULLIF(current_setting('app.current_realm_id', true), '')::UUID;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Set current guest session ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_current_guest_session_id(guest_session_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('app.current_guest_session_id', guest_session_id::TEXT, false);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Get current guest session ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.current_guest_session_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN NULLIF(current_setting('app.current_guest_session_id', true), '')::UUID;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Check if current user is a server admin
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_server_admin()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM server.staff
|
||||||
|
WHERE user_id = public.current_user_id()
|
||||||
|
AND role IN ('owner', 'admin')
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN FALSE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Check if current user is a server moderator (or higher)
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_server_moderator()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM server.staff
|
||||||
|
WHERE user_id = public.current_user_id()
|
||||||
|
AND role IN ('owner', 'admin', 'moderator')
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN FALSE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Check if current user is a realm owner/moderator
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_realm_moderator(check_realm_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Server admins are moderators everywhere
|
||||||
|
IF public.is_server_admin() THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM realm.memberships
|
||||||
|
WHERE realm_id = check_realm_id
|
||||||
|
AND user_id = public.current_user_id()
|
||||||
|
AND role IN ('owner', 'moderator')
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN FALSE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Check if current user has membership in a realm
|
||||||
|
CREATE OR REPLACE FUNCTION public.has_realm_membership(check_realm_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM realm.memberships
|
||||||
|
WHERE realm_id = check_realm_id
|
||||||
|
AND user_id = public.current_user_id()
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN FALSE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Friendship Helper Functions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Check if two users are friends
|
||||||
|
CREATE OR REPLACE FUNCTION auth.are_friends(user_a UUID, user_b UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM auth.friendships
|
||||||
|
WHERE friend_a = LEAST(user_a, user_b)
|
||||||
|
AND friend_b = GREATEST(user_a, user_b)
|
||||||
|
AND is_accepted = true
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Check if user_a has blocked user_b
|
||||||
|
CREATE OR REPLACE FUNCTION auth.has_blocked(blocker UUID, blocked UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM auth.blocks
|
||||||
|
WHERE blocker_id = blocker
|
||||||
|
AND blocked_id = blocked
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Check if either user has blocked the other
|
||||||
|
CREATE OR REPLACE FUNCTION auth.is_blocked_either_way(user_a UUID, user_b UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM auth.blocks
|
||||||
|
WHERE (blocker_id = user_a AND blocked_id = user_b)
|
||||||
|
OR (blocker_id = user_b AND blocked_id = user_a)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Spatial Helper Functions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Create a virtual point from x,y coordinates
|
||||||
|
CREATE OR REPLACE FUNCTION public.make_virtual_point(x REAL, y REAL)
|
||||||
|
RETURNS public.virtual_point AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN ST_SetSRID(ST_MakePoint(x, y), 0)::public.virtual_point;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Create scene bounds from width and height (origin at 0,0)
|
||||||
|
CREATE OR REPLACE FUNCTION public.make_scene_bounds(width REAL, height REAL)
|
||||||
|
RETURNS public.scene_bounds AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN ST_MakeEnvelope(0, 0, width, height, 0)::public.scene_bounds;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Get distance between two virtual points
|
||||||
|
CREATE OR REPLACE FUNCTION public.virtual_distance(p1 public.virtual_point, p2 public.virtual_point)
|
||||||
|
RETURNS REAL AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN ST_Distance(p1, p2)::REAL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Check if a point is within scene bounds
|
||||||
|
CREATE OR REPLACE FUNCTION public.point_in_bounds(
|
||||||
|
point public.virtual_point,
|
||||||
|
bounds public.scene_bounds
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN ST_Within(point, bounds);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Audit Helper Functions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Log an audit event
|
||||||
|
CREATE OR REPLACE FUNCTION audit.log_event(
|
||||||
|
p_category audit.event_category,
|
||||||
|
p_action TEXT,
|
||||||
|
p_target_type TEXT DEFAULT NULL,
|
||||||
|
p_target_id UUID DEFAULT NULL,
|
||||||
|
p_details JSONB DEFAULT '{}',
|
||||||
|
p_success BOOLEAN DEFAULT TRUE,
|
||||||
|
p_error_message TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_event_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit.events (
|
||||||
|
category,
|
||||||
|
action,
|
||||||
|
user_id,
|
||||||
|
ip_address,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
realm_id,
|
||||||
|
details,
|
||||||
|
success,
|
||||||
|
error_message
|
||||||
|
) VALUES (
|
||||||
|
p_category,
|
||||||
|
p_action,
|
||||||
|
public.current_user_id(),
|
||||||
|
NULLIF(current_setting('app.client_ip', true), '')::INET,
|
||||||
|
p_target_type,
|
||||||
|
p_target_id,
|
||||||
|
public.current_realm_id(),
|
||||||
|
p_details,
|
||||||
|
p_success,
|
||||||
|
p_error_message
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_event_id;
|
||||||
|
|
||||||
|
RETURN v_event_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION audit.log_event IS 'Helper to create audit log entries';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
170
db/schema/functions/002_user_init.sql
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
-- Chattyness User Initialization Functions
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Functions to initialize new users with default props and avatars.
|
||||||
|
-- Load via: psql -f schema/functions/002_user_init.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Initialize New User with Default Props and Avatar
|
||||||
|
-- =============================================================================
|
||||||
|
-- Called when a new user is created to give them:
|
||||||
|
-- 1. All face-tagged server props in their inventory
|
||||||
|
-- 2. A default avatar (slot 0) with the Face prop and all emotions configured
|
||||||
|
--
|
||||||
|
-- Note: active_avatars entry is NOT created here - it's created when the user
|
||||||
|
-- joins a realm for the first time (per-realm avatar state).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.initialize_new_user(p_user_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_avatar_id UUID;
|
||||||
|
v_face_inventory_id UUID;
|
||||||
|
v_neutral_inventory_id UUID;
|
||||||
|
v_happy_inventory_id UUID;
|
||||||
|
v_sad_inventory_id UUID;
|
||||||
|
v_angry_inventory_id UUID;
|
||||||
|
v_surprised_inventory_id UUID;
|
||||||
|
v_thinking_inventory_id UUID;
|
||||||
|
v_laughing_inventory_id UUID;
|
||||||
|
v_crying_inventory_id UUID;
|
||||||
|
v_love_inventory_id UUID;
|
||||||
|
v_confused_inventory_id UUID;
|
||||||
|
v_sleeping_inventory_id UUID;
|
||||||
|
v_wink_inventory_id UUID;
|
||||||
|
v_prop RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Insert all face-tagged server props into user's inventory
|
||||||
|
-- Note: inventory layer/position are only for content layer props (skin/clothes/accessories).
|
||||||
|
-- Emotion props have default_emotion instead of default_layer, so they get NULL layer/position.
|
||||||
|
FOR v_prop IN
|
||||||
|
SELECT id, name, asset_path, default_layer, default_emotion, default_position, slug,
|
||||||
|
is_transferable, is_portable, is_droppable
|
||||||
|
FROM server.props
|
||||||
|
WHERE tags @> ARRAY['face']
|
||||||
|
AND is_active = true
|
||||||
|
LOOP
|
||||||
|
-- Use a local variable for the inserted inventory ID
|
||||||
|
DECLARE
|
||||||
|
v_new_inventory_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO auth.inventory (
|
||||||
|
user_id,
|
||||||
|
server_prop_id,
|
||||||
|
prop_name,
|
||||||
|
prop_asset_path,
|
||||||
|
layer,
|
||||||
|
position,
|
||||||
|
origin,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_droppable
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
p_user_id,
|
||||||
|
v_prop.id,
|
||||||
|
v_prop.name,
|
||||||
|
v_prop.asset_path,
|
||||||
|
v_prop.default_layer, -- NULL for emotion props
|
||||||
|
CASE WHEN v_prop.default_layer IS NOT NULL THEN v_prop.default_position ELSE NULL END,
|
||||||
|
'server_library',
|
||||||
|
v_prop.is_transferable,
|
||||||
|
v_prop.is_portable,
|
||||||
|
v_prop.is_droppable
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_new_inventory_id;
|
||||||
|
|
||||||
|
-- Track inventory IDs for avatar assignment based on slug
|
||||||
|
CASE v_prop.slug
|
||||||
|
WHEN 'face' THEN v_face_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'neutral' THEN v_neutral_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'smile' THEN v_happy_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'sad' THEN v_sad_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'angry' THEN v_angry_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'surprised' THEN v_surprised_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'thinking' THEN v_thinking_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'laughing' THEN v_laughing_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'crying' THEN v_crying_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'love' THEN v_love_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'confused' THEN v_confused_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'sleeping' THEN v_sleeping_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'wink' THEN v_wink_inventory_id := v_new_inventory_id;
|
||||||
|
ELSE NULL;
|
||||||
|
END CASE;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Create default avatar (slot 0) with the Face prop in skin layer
|
||||||
|
-- and all emotion props in their respective emotion slots at position 4 (center)
|
||||||
|
INSERT INTO auth.avatars (
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
slot_number,
|
||||||
|
last_emotion,
|
||||||
|
-- Content layer: Face goes in skin layer, center position
|
||||||
|
l_skin_4,
|
||||||
|
-- Emotion layers: Each emotion prop goes to its matching emotion at center position
|
||||||
|
e_neutral_4,
|
||||||
|
e_happy_4,
|
||||||
|
e_sad_4,
|
||||||
|
e_angry_4,
|
||||||
|
e_surprised_4,
|
||||||
|
e_thinking_4,
|
||||||
|
e_laughing_4,
|
||||||
|
e_crying_4,
|
||||||
|
e_love_4,
|
||||||
|
e_confused_4,
|
||||||
|
e_sleeping_4,
|
||||||
|
e_wink_4
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
p_user_id,
|
||||||
|
'Default',
|
||||||
|
0,
|
||||||
|
0, -- Start with neutral emotion
|
||||||
|
v_face_inventory_id,
|
||||||
|
v_neutral_inventory_id,
|
||||||
|
v_happy_inventory_id,
|
||||||
|
v_sad_inventory_id,
|
||||||
|
v_angry_inventory_id,
|
||||||
|
v_surprised_inventory_id,
|
||||||
|
v_thinking_inventory_id,
|
||||||
|
v_laughing_inventory_id,
|
||||||
|
v_crying_inventory_id,
|
||||||
|
v_love_inventory_id,
|
||||||
|
v_confused_inventory_id,
|
||||||
|
v_sleeping_inventory_id,
|
||||||
|
v_wink_inventory_id
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_avatar_id;
|
||||||
|
|
||||||
|
-- Note: We don't create an active_avatars entry here because that's per-realm.
|
||||||
|
-- The active_avatars entry will be created when the user first joins a realm.
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION auth.initialize_new_user(UUID) IS
|
||||||
|
'Initialize a new user with default props in inventory and a default avatar configuration';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Trigger Function for User Registration
|
||||||
|
-- =============================================================================
|
||||||
|
-- Wrapper trigger function that calls initialize_new_user.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.initialize_new_user_trigger()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM auth.initialize_new_user(NEW.id);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION auth.initialize_new_user_trigger() IS
|
||||||
|
'Trigger function to initialize new users on registration';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
197
db/schema/functions/003_admin_restore_props.sql
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
-- Chattyness Admin Restore Props Function
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Admin function to restore missing essential props for users.
|
||||||
|
-- Load via: psql -f schema/functions/003_admin_restore_props.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Restore Essential Props for a Single User
|
||||||
|
-- =============================================================================
|
||||||
|
-- Restores missing face-tagged server props to a user's inventory and fixes
|
||||||
|
-- any broken avatar slot references.
|
||||||
|
--
|
||||||
|
-- Usage: SELECT * FROM auth.restore_essential_props('username');
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.restore_essential_props(p_username TEXT)
|
||||||
|
RETURNS TABLE(
|
||||||
|
action TEXT,
|
||||||
|
prop_slug TEXT,
|
||||||
|
inventory_id UUID
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_prop RECORD;
|
||||||
|
v_inventory_id UUID;
|
||||||
|
v_avatar_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Get user ID
|
||||||
|
SELECT id INTO v_user_id FROM auth.users WHERE username = p_username;
|
||||||
|
IF v_user_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'User not found: %', p_username;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- For each face-tagged server prop
|
||||||
|
FOR v_prop IN
|
||||||
|
SELECT id, name, asset_path, default_layer, default_emotion, default_position, slug,
|
||||||
|
is_transferable, is_portable, is_droppable
|
||||||
|
FROM server.props
|
||||||
|
WHERE tags @> ARRAY['face'] AND is_active = true
|
||||||
|
LOOP
|
||||||
|
-- Check if user already has this prop
|
||||||
|
SELECT inv.id INTO v_inventory_id
|
||||||
|
FROM auth.inventory inv
|
||||||
|
WHERE inv.user_id = v_user_id AND inv.server_prop_id = v_prop.id;
|
||||||
|
|
||||||
|
IF v_inventory_id IS NULL THEN
|
||||||
|
-- Insert missing prop
|
||||||
|
INSERT INTO auth.inventory (
|
||||||
|
user_id, server_prop_id, prop_name, prop_asset_path,
|
||||||
|
layer, position, origin,
|
||||||
|
is_transferable, is_portable, is_droppable
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
v_user_id, v_prop.id, v_prop.name, v_prop.asset_path,
|
||||||
|
v_prop.default_layer,
|
||||||
|
CASE WHEN v_prop.default_layer IS NOT NULL THEN v_prop.default_position ELSE NULL END,
|
||||||
|
'server_library',
|
||||||
|
v_prop.is_transferable, v_prop.is_portable, v_prop.is_droppable
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_inventory_id;
|
||||||
|
|
||||||
|
action := 'restored';
|
||||||
|
prop_slug := v_prop.slug;
|
||||||
|
inventory_id := v_inventory_id;
|
||||||
|
RETURN NEXT;
|
||||||
|
ELSE
|
||||||
|
action := 'exists';
|
||||||
|
prop_slug := v_prop.slug;
|
||||||
|
inventory_id := v_inventory_id;
|
||||||
|
RETURN NEXT;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Get the user's default avatar (slot 0)
|
||||||
|
SELECT id INTO v_avatar_id FROM auth.avatars WHERE user_id = v_user_id AND slot_number = 0;
|
||||||
|
|
||||||
|
IF v_avatar_id IS NOT NULL THEN
|
||||||
|
-- Update avatar slots with correct inventory references where NULL
|
||||||
|
UPDATE auth.avatars a
|
||||||
|
SET
|
||||||
|
l_skin_4 = COALESCE(a.l_skin_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'face'
|
||||||
|
)),
|
||||||
|
e_neutral_4 = COALESCE(a.e_neutral_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'neutral'
|
||||||
|
)),
|
||||||
|
e_happy_4 = COALESCE(a.e_happy_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'smile'
|
||||||
|
)),
|
||||||
|
e_sad_4 = COALESCE(a.e_sad_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'sad'
|
||||||
|
)),
|
||||||
|
e_angry_4 = COALESCE(a.e_angry_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'angry'
|
||||||
|
)),
|
||||||
|
e_surprised_4 = COALESCE(a.e_surprised_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'surprised'
|
||||||
|
)),
|
||||||
|
e_thinking_4 = COALESCE(a.e_thinking_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'thinking'
|
||||||
|
)),
|
||||||
|
e_laughing_4 = COALESCE(a.e_laughing_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'laughing'
|
||||||
|
)),
|
||||||
|
e_crying_4 = COALESCE(a.e_crying_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'crying'
|
||||||
|
)),
|
||||||
|
e_love_4 = COALESCE(a.e_love_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'love'
|
||||||
|
)),
|
||||||
|
e_confused_4 = COALESCE(a.e_confused_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'confused'
|
||||||
|
)),
|
||||||
|
e_sleeping_4 = COALESCE(a.e_sleeping_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'sleeping'
|
||||||
|
)),
|
||||||
|
e_wink_4 = COALESCE(a.e_wink_4, (
|
||||||
|
SELECT inv.id FROM auth.inventory inv
|
||||||
|
JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
WHERE inv.user_id = v_user_id AND sp.slug = 'wink'
|
||||||
|
))
|
||||||
|
WHERE a.id = v_avatar_id;
|
||||||
|
|
||||||
|
action := 'avatar_fixed';
|
||||||
|
prop_slug := NULL;
|
||||||
|
inventory_id := v_avatar_id;
|
||||||
|
RETURN NEXT;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION auth.restore_essential_props(TEXT) IS
|
||||||
|
'Restore missing essential (face-tagged) props to a user''s inventory and fix avatar slots';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Restore Essential Props for All Users
|
||||||
|
-- =============================================================================
|
||||||
|
-- Batch operation to restore props for all users.
|
||||||
|
--
|
||||||
|
-- Usage: SELECT * FROM auth.restore_essential_props_all_users();
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.restore_essential_props_all_users()
|
||||||
|
RETURNS TABLE(
|
||||||
|
username TEXT,
|
||||||
|
props_restored INTEGER
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user RECORD;
|
||||||
|
v_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
FOR v_user IN SELECT id, u.username FROM auth.users u LOOP
|
||||||
|
SELECT COUNT(*) INTO v_count
|
||||||
|
FROM auth.restore_essential_props(v_user.username)
|
||||||
|
WHERE action = 'restored';
|
||||||
|
|
||||||
|
IF v_count > 0 THEN
|
||||||
|
username := v_user.username;
|
||||||
|
props_restored := v_count;
|
||||||
|
RETURN NEXT;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION auth.restore_essential_props_all_users() IS
|
||||||
|
'Restore missing essential props for all users - returns users who had props restored';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
122
db/schema/load.sql
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
-- Chattyness Database Schema Loader
|
||||||
|
-- PostgreSQL 18 with PostGIS
|
||||||
|
--
|
||||||
|
-- Master file to load all schema components in correct order.
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- psql -d your_database -f schema/load.sql
|
||||||
|
--
|
||||||
|
-- Or from psql:
|
||||||
|
-- \i schema/load.sql
|
||||||
|
--
|
||||||
|
-- Prerequisites:
|
||||||
|
-- - PostgreSQL 18 with PostGIS extension available
|
||||||
|
-- - Database already created
|
||||||
|
-- - Superuser or appropriate privileges
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
\timing on
|
||||||
|
|
||||||
|
\echo '=============================================='
|
||||||
|
\echo 'Chattyness Database Schema Loader'
|
||||||
|
\echo '=============================================='
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Phase 1: Initialization (schemas, extensions, roles)
|
||||||
|
-- =============================================================================
|
||||||
|
\echo 'Phase 1: Initialization...'
|
||||||
|
\ir 000_init.sql
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Phase 2: Types (ENUMs, domains)
|
||||||
|
-- =============================================================================
|
||||||
|
\echo 'Phase 2: Creating types...'
|
||||||
|
\ir types/001_enums.sql
|
||||||
|
\ir types/002_domains.sql
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Phase 3: Tables (in dependency order)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Schema: server (010) → auth (020) → realm (030) → scene (045) → chat (050) → audit (080)
|
||||||
|
-- Note: props and moderation schemas removed; tables distributed to server/auth/realm/scene
|
||||||
|
\echo 'Phase 3: Creating tables...'
|
||||||
|
\ir tables/010_server.sql
|
||||||
|
\ir tables/020_auth.sql
|
||||||
|
\ir tables/030_realm.sql
|
||||||
|
\ir tables/045_scene.sql
|
||||||
|
\ir tables/050_chat.sql
|
||||||
|
\ir tables/080_audit.sql
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Phase 4: Functions
|
||||||
|
-- =============================================================================
|
||||||
|
\echo 'Phase 4: Creating functions...'
|
||||||
|
\ir functions/001_helpers.sql
|
||||||
|
\ir functions/002_user_init.sql
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Phase 5: Triggers
|
||||||
|
-- =============================================================================
|
||||||
|
\echo 'Phase 5: Creating triggers...'
|
||||||
|
\ir triggers/001_updated_at.sql
|
||||||
|
\ir triggers/002_user_init.sql
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Phase 6: Row-Level Security Policies
|
||||||
|
-- =============================================================================
|
||||||
|
\echo 'Phase 6: Enabling Row-Level Security...'
|
||||||
|
\ir policies/001_rls.sql
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Complete
|
||||||
|
-- =============================================================================
|
||||||
|
\echo '=============================================='
|
||||||
|
\echo 'Schema loaded successfully!'
|
||||||
|
\echo '=============================================='
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Phase 7: Set Passwords for Application Roles
|
||||||
|
-- =============================================================================
|
||||||
|
\echo 'Phase 7: Setting up application credentials...'
|
||||||
|
|
||||||
|
-- Generate secure passwords
|
||||||
|
\set app_password `pwgen -s 80 1`
|
||||||
|
\set owner_password `pwgen -s 80 1`
|
||||||
|
|
||||||
|
-- Set passwords for roles (created in init.sql)
|
||||||
|
ALTER ROLE chattyness_app WITH PASSWORD :'app_password';
|
||||||
|
ALTER ROLE chattyness_owner WITH PASSWORD :'owner_password';
|
||||||
|
|
||||||
|
\echo ''
|
||||||
|
\echo '=============================================='
|
||||||
|
\echo 'Application Credentials Set'
|
||||||
|
\echo '=============================================='
|
||||||
|
\echo ''
|
||||||
|
\echo 'chattyness_app (application role, subject to RLS):'
|
||||||
|
\echo ' Password: ' :app_password
|
||||||
|
\echo ''
|
||||||
|
\echo 'chattyness_owner (owner role, bypasses RLS):'
|
||||||
|
\echo ' Password: ' :owner_password
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
-- Write .env file
|
||||||
|
\! echo "# Chattyness Database Credentials" > .env
|
||||||
|
\! echo "# Generated by load.sql" >> .env
|
||||||
|
\! echo "" >> .env
|
||||||
|
\set write_app `echo "export DB_CHATTYNESS_APP=":app_password >> .env`
|
||||||
|
\set write_owner `echo "export DB_CHATTYNESS_OWNER=":owner_password >> .env`
|
||||||
|
|
||||||
|
\echo 'Credentials written to: .env'
|
||||||
|
\echo ''
|
||||||
|
\echo 'Login roles:'
|
||||||
|
\echo ' chattyness_app - Application operations (subject to RLS)'
|
||||||
|
\echo ' chattyness_owner - Owner operations (bypasses RLS)'
|
||||||
|
\echo ''
|
||||||
985
db/schema/policies/001_rls.sql
Normal file
|
|
@ -0,0 +1,985 @@
|
||||||
|
-- Chattyness Row-Level Security Policies
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- RLS policies for data isolation and access control
|
||||||
|
-- Load via: psql -f schema/policies/001_rls.sql
|
||||||
|
--
|
||||||
|
-- IMPORTANT: The application must set session variables before queries:
|
||||||
|
--
|
||||||
|
-- For authenticated users:
|
||||||
|
-- SELECT public.set_current_user_id('user-uuid-here');
|
||||||
|
-- SELECT public.set_current_realm_id('realm-uuid-here'); -- when in a realm
|
||||||
|
--
|
||||||
|
-- For guest users:
|
||||||
|
-- SELECT public.set_current_user_id(NULL);
|
||||||
|
-- SELECT public.set_current_guest_session_id('guest-session-uuid-here');
|
||||||
|
-- SELECT public.set_current_realm_id('realm-uuid-here'); -- when in a realm
|
||||||
|
--
|
||||||
|
-- The chattyness_app role is used by the application backend.
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Grant Usage on Schemas to Application Role
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA server TO chattyness_app;
|
||||||
|
GRANT USAGE ON SCHEMA auth TO chattyness_app;
|
||||||
|
GRANT USAGE ON SCHEMA realm TO chattyness_app;
|
||||||
|
GRANT USAGE ON SCHEMA scene TO chattyness_app;
|
||||||
|
GRANT USAGE ON SCHEMA chat TO chattyness_app;
|
||||||
|
GRANT USAGE ON SCHEMA audit TO chattyness_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SERVER SCHEMA POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server data is readable by all, writable only by server admins.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- server.config
|
||||||
|
ALTER TABLE server.config ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_config_select ON server.config
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
GRANT SELECT ON server.config TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.staff
|
||||||
|
ALTER TABLE server.staff ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_staff_select ON server.staff
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
GRANT SELECT ON server.staff TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.props
|
||||||
|
ALTER TABLE server.props ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_props_select ON server.props
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_props_insert ON server.props
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
CREATE POLICY server_props_update ON server.props
|
||||||
|
FOR UPDATE TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
CREATE POLICY server_props_delete ON server.props
|
||||||
|
FOR DELETE TO chattyness_app
|
||||||
|
USING (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT ON server.props TO chattyness_app;
|
||||||
|
GRANT INSERT, UPDATE, DELETE ON server.props TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.audio
|
||||||
|
ALTER TABLE server.audio ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_audio_select ON server.audio
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_audio_modify ON server.audio
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON server.audio TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.reserved_names
|
||||||
|
ALTER TABLE server.reserved_names ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_reserved_names_select ON server.reserved_names
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_reserved_names_modify ON server.reserved_names
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON server.reserved_names TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.scripts
|
||||||
|
ALTER TABLE server.scripts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_scripts_select ON server.scripts
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_scripts_modify ON server.scripts
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON server.scripts TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.ip_bans
|
||||||
|
ALTER TABLE server.ip_bans ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_ip_bans_select ON server.ip_bans
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_ip_bans_modify ON server.ip_bans
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON server.ip_bans TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.bans
|
||||||
|
ALTER TABLE server.bans ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_bans_select ON server.bans
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_bans_modify ON server.bans
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_moderator())
|
||||||
|
WITH CHECK (public.is_server_moderator());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON server.bans TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.mutes
|
||||||
|
ALTER TABLE server.mutes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_mutes_select ON server.mutes
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_mutes_modify ON server.mutes
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_moderator())
|
||||||
|
WITH CHECK (public.is_server_moderator());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON server.mutes TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.content_filters
|
||||||
|
ALTER TABLE server.content_filters ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_content_filters_select ON server.content_filters
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY server_content_filters_modify ON server.content_filters
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON server.content_filters TO chattyness_app;
|
||||||
|
|
||||||
|
-- server.moderation_actions
|
||||||
|
ALTER TABLE server.moderation_actions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY server_moderation_actions_target ON server.moderation_actions
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (target_user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY server_moderation_actions_mod ON server.moderation_actions
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_moderator())
|
||||||
|
WITH CHECK (public.is_server_moderator());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT ON server.moderation_actions TO chattyness_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- AUTH SCHEMA POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- auth.users
|
||||||
|
ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_users_select ON auth.users
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
id = public.current_user_id()
|
||||||
|
OR NOT auth.has_blocked(id, public.current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY auth_users_update ON auth.users
|
||||||
|
FOR UPDATE TO chattyness_app
|
||||||
|
USING (id = public.current_user_id())
|
||||||
|
WITH CHECK (id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY auth_users_insert ON auth.users
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
CREATE POLICY auth_users_admin ON auth.users
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, UPDATE ON auth.users TO chattyness_app;
|
||||||
|
GRANT INSERT, DELETE ON auth.users TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.sessions
|
||||||
|
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_sessions_own ON auth.sessions
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id())
|
||||||
|
WITH CHECK (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY auth_sessions_admin ON auth.sessions
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.sessions TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.guest_sessions
|
||||||
|
ALTER TABLE auth.guest_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_guest_sessions_all ON auth.guest_sessions
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.guest_sessions TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.tower_sessions
|
||||||
|
ALTER TABLE auth.tower_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_tower_sessions_all ON auth.tower_sessions
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.tower_sessions TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.friendships
|
||||||
|
ALTER TABLE auth.friendships ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_friendships_own ON auth.friendships
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
friend_a = public.current_user_id()
|
||||||
|
OR friend_b = public.current_user_id()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
friend_a = public.current_user_id()
|
||||||
|
OR friend_b = public.current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.friendships TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.blocks
|
||||||
|
ALTER TABLE auth.blocks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_blocks_own ON auth.blocks
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (blocker_id = public.current_user_id())
|
||||||
|
WITH CHECK (blocker_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY auth_blocks_mod ON auth.blocks
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (public.is_server_moderator());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, DELETE ON auth.blocks TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.mutes (user personal mutes)
|
||||||
|
ALTER TABLE auth.mutes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_mutes_own ON auth.mutes
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (muter_id = public.current_user_id())
|
||||||
|
WITH CHECK (muter_id = public.current_user_id());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, DELETE ON auth.mutes TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.user_scripts
|
||||||
|
ALTER TABLE auth.user_scripts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_user_scripts_own ON auth.user_scripts
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id())
|
||||||
|
WITH CHECK (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY auth_user_scripts_admin ON auth.user_scripts
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.user_scripts TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.inventory
|
||||||
|
ALTER TABLE auth.inventory ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_inventory_own ON auth.inventory
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id())
|
||||||
|
WITH CHECK (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY auth_inventory_view ON auth.inventory
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.inventory TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.avatars
|
||||||
|
ALTER TABLE auth.avatars ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_avatars_own ON auth.avatars
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id())
|
||||||
|
WITH CHECK (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY auth_avatars_view ON auth.avatars
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.avatars TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.active_avatars
|
||||||
|
ALTER TABLE auth.active_avatars ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_active_avatars_own ON auth.active_avatars
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id())
|
||||||
|
WITH CHECK (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY auth_active_avatars_view ON auth.active_avatars
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.active_avatars TO chattyness_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- REALM SCHEMA POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- realm.realms
|
||||||
|
ALTER TABLE realm.realms ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_realms_select ON realm.realms
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
privacy = 'public'
|
||||||
|
OR privacy = 'unlisted'
|
||||||
|
OR owner_id = public.current_user_id()
|
||||||
|
OR public.has_realm_membership(id)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY realm_realms_owner ON realm.realms
|
||||||
|
FOR UPDATE TO chattyness_app
|
||||||
|
USING (owner_id = public.current_user_id())
|
||||||
|
WITH CHECK (owner_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY realm_realms_insert ON realm.realms
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (owner_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY realm_realms_delete ON realm.realms
|
||||||
|
FOR DELETE TO chattyness_app
|
||||||
|
USING (owner_id = public.current_user_id() OR public.is_server_admin());
|
||||||
|
|
||||||
|
CREATE POLICY realm_realms_admin ON realm.realms
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_server_admin())
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.realms TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.scenes
|
||||||
|
ALTER TABLE realm.scenes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_scenes_select ON realm.scenes
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.realms r
|
||||||
|
WHERE r.id = realm_id
|
||||||
|
AND (
|
||||||
|
r.privacy IN ('public', 'unlisted')
|
||||||
|
OR r.owner_id = public.current_user_id()
|
||||||
|
OR public.has_realm_membership(r.id)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY realm_scenes_modify ON realm.scenes
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.memberships m
|
||||||
|
WHERE m.realm_id = realm.scenes.realm_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.memberships m
|
||||||
|
WHERE m.realm_id = realm.scenes.realm_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.scenes TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.memberships
|
||||||
|
ALTER TABLE realm.memberships ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_memberships_select ON realm.memberships
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
user_id = public.current_user_id()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM realm.realms r
|
||||||
|
WHERE r.id = realm_id
|
||||||
|
AND (r.privacy = 'public' OR public.has_realm_membership(r.id))
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY realm_memberships_own ON realm.memberships
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id())
|
||||||
|
WITH CHECK (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY realm_memberships_mod ON realm.memberships
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (public.is_realm_moderator(realm_id))
|
||||||
|
WITH CHECK (public.is_realm_moderator(realm_id));
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.memberships TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.realm_scripts
|
||||||
|
ALTER TABLE realm.realm_scripts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_realm_scripts_select ON realm.realm_scripts
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.realms r
|
||||||
|
WHERE r.id = realm_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY realm_realm_scripts_modify ON realm.realm_scripts
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.memberships m
|
||||||
|
WHERE m.realm_id = realm.realm_scripts.realm_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.realm_scripts TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.props
|
||||||
|
ALTER TABLE realm.props ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_props_select ON realm.props
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
public.has_realm_membership(realm_id)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY realm_props_modify ON realm.props
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.memberships m
|
||||||
|
WHERE m.realm_id = realm.props.realm_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.props TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.reports
|
||||||
|
ALTER TABLE realm.reports ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_reports_own ON realm.reports
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (reporter_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY realm_reports_insert ON realm.reports
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (reporter_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY realm_reports_mod ON realm.reports
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
public.is_server_moderator()
|
||||||
|
OR public.is_realm_moderator(realm_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON realm.reports TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.bans
|
||||||
|
ALTER TABLE realm.bans ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_bans_select ON realm.bans
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY realm_bans_modify ON realm.bans
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
public.is_server_moderator()
|
||||||
|
OR public.is_realm_moderator(realm_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.bans TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.mutes (realm moderation mutes)
|
||||||
|
ALTER TABLE realm.mutes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_mutes_select ON realm.mutes
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY realm_mutes_modify ON realm.mutes
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
public.is_server_moderator()
|
||||||
|
OR public.is_realm_moderator(realm_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.mutes TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.content_filters
|
||||||
|
ALTER TABLE realm.content_filters ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_content_filters_select ON realm.content_filters
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
public.has_realm_membership(realm_id)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY realm_content_filters_modify ON realm.content_filters
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.memberships m
|
||||||
|
WHERE m.realm_id = realm.content_filters.realm_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'moderator')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.content_filters TO chattyness_app;
|
||||||
|
|
||||||
|
-- realm.moderation_actions
|
||||||
|
ALTER TABLE realm.moderation_actions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY realm_moderation_actions_target ON realm.moderation_actions
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (target_user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY realm_moderation_actions_mod ON realm.moderation_actions
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
public.is_server_moderator()
|
||||||
|
OR public.is_realm_moderator(realm_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT ON realm.moderation_actions TO chattyness_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- SCENE SCHEMA POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- scene.instances
|
||||||
|
ALTER TABLE scene.instances ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_instances_select ON scene.instances
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
instance_type = 'public'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
JOIN realm.realms r ON r.id = s.realm_id
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
AND (
|
||||||
|
r.privacy IN ('public', 'unlisted')
|
||||||
|
OR r.owner_id = public.current_user_id()
|
||||||
|
OR public.has_realm_membership(r.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
(
|
||||||
|
instance_type = 'private'
|
||||||
|
AND (
|
||||||
|
created_by = public.current_user_id()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM scene.instance_invites ii
|
||||||
|
WHERE ii.instance_id = scene.instances.id
|
||||||
|
AND ii.invited_user_id = public.current_user_id()
|
||||||
|
AND ii.accepted_at IS NOT NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_instances_insert ON scene.instances
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
CREATE POLICY scene_instances_modify ON scene.instances
|
||||||
|
FOR UPDATE TO chattyness_app
|
||||||
|
USING (
|
||||||
|
created_by = public.current_user_id()
|
||||||
|
OR public.is_realm_moderator(
|
||||||
|
(SELECT s.realm_id FROM realm.scenes s WHERE s.id = scene_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_instances_delete ON scene.instances
|
||||||
|
FOR DELETE TO chattyness_app
|
||||||
|
USING (
|
||||||
|
created_by = public.current_user_id()
|
||||||
|
OR public.is_realm_moderator(
|
||||||
|
(SELECT s.realm_id FROM realm.scenes s WHERE s.id = scene_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.instances TO chattyness_app;
|
||||||
|
|
||||||
|
-- scene.instance_members
|
||||||
|
ALTER TABLE scene.instance_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_instance_members_select ON scene.instance_members
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
WHERE s.id = instance_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_instance_members_own ON scene.instance_members
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.instance_members TO chattyness_app;
|
||||||
|
|
||||||
|
-- scene.instance_invites
|
||||||
|
ALTER TABLE scene.instance_invites ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_instance_invites_select ON scene.instance_invites
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
invited_user_id = public.current_user_id()
|
||||||
|
OR invited_by = public.current_user_id()
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_instance_invites_own ON scene.instance_invites
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
invited_user_id = public.current_user_id()
|
||||||
|
OR invited_by = public.current_user_id()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
invited_by = public.current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.instance_invites TO chattyness_app;
|
||||||
|
|
||||||
|
-- scene.spots
|
||||||
|
ALTER TABLE scene.spots ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_spots_select ON scene.spots
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_spots_modify ON scene.spots
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
JOIN realm.memberships m ON m.realm_id = s.realm_id
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
JOIN realm.memberships m ON m.realm_id = s.realm_id
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.spots TO chattyness_app;
|
||||||
|
|
||||||
|
-- scene.spot_states
|
||||||
|
ALTER TABLE scene.spot_states ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_spot_states_select ON scene.spot_states
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM scene.spots sp
|
||||||
|
WHERE sp.id = spot_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_spot_states_modify ON scene.spot_states
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM scene.spots sp
|
||||||
|
JOIN realm.scenes s ON s.id = sp.scene_id
|
||||||
|
JOIN realm.memberships m ON m.realm_id = s.realm_id
|
||||||
|
WHERE sp.id = spot_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM scene.spots sp
|
||||||
|
JOIN realm.scenes s ON s.id = sp.scene_id
|
||||||
|
JOIN realm.memberships m ON m.realm_id = s.realm_id
|
||||||
|
WHERE sp.id = spot_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.spot_states TO chattyness_app;
|
||||||
|
|
||||||
|
-- scene.scripts
|
||||||
|
ALTER TABLE scene.scripts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_scripts_select ON scene.scripts
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_scripts_modify ON scene.scripts
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
JOIN realm.memberships m ON m.realm_id = s.realm_id
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.scripts TO chattyness_app;
|
||||||
|
|
||||||
|
-- scene.loose_props
|
||||||
|
ALTER TABLE scene.loose_props ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_loose_props_select ON scene.loose_props
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM scene.instances i
|
||||||
|
WHERE i.id = instance_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_loose_props_modify ON scene.loose_props
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.loose_props TO chattyness_app;
|
||||||
|
|
||||||
|
-- scene.decorations
|
||||||
|
ALTER TABLE scene.decorations ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY scene_decorations_select ON scene.decorations
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY scene_decorations_modify ON scene.decorations
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM realm.scenes s
|
||||||
|
JOIN realm.memberships m ON m.realm_id = s.realm_id
|
||||||
|
WHERE s.id = scene_id
|
||||||
|
AND m.user_id = public.current_user_id()
|
||||||
|
AND m.role IN ('owner', 'builder')
|
||||||
|
)
|
||||||
|
OR public.is_server_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.decorations TO chattyness_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- CHAT SCHEMA POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- chat.messages
|
||||||
|
ALTER TABLE chat.messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY chat_messages_select ON chat.messages
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
(NOT is_deleted OR public.is_server_moderator())
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM scene.instances i
|
||||||
|
WHERE i.id = instance_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY chat_messages_insert ON chat.messages
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (
|
||||||
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY chat_messages_update ON chat.messages
|
||||||
|
FOR UPDATE TO chattyness_app
|
||||||
|
USING (
|
||||||
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
|
OR public.is_server_moderator()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON chat.messages TO chattyness_app;
|
||||||
|
|
||||||
|
-- chat.whispers
|
||||||
|
ALTER TABLE chat.whispers ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY chat_whispers_own ON chat.whispers
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (
|
||||||
|
sender_id = public.current_user_id()
|
||||||
|
OR recipient_id = public.current_user_id()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
sender_id = public.current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON chat.whispers TO chattyness_app;
|
||||||
|
|
||||||
|
-- chat.reactions
|
||||||
|
ALTER TABLE chat.reactions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY chat_reactions_select ON chat.reactions
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY chat_reactions_own ON chat.reactions
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id())
|
||||||
|
WITH CHECK (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, DELETE ON chat.reactions TO chattyness_app;
|
||||||
|
|
||||||
|
-- chat.shouts
|
||||||
|
ALTER TABLE chat.shouts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY chat_shouts_select ON chat.shouts
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY chat_shouts_insert ON chat.shouts
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (public.is_server_admin());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT ON chat.shouts TO chattyness_app;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- AUDIT SCHEMA POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- audit.events
|
||||||
|
ALTER TABLE audit.events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY audit_events_own ON audit.events
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY audit_events_admin ON audit.events
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (public.is_server_admin());
|
||||||
|
|
||||||
|
CREATE POLICY audit_events_realm ON audit.events
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (
|
||||||
|
realm_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM realm.realms r
|
||||||
|
WHERE r.id = realm_id
|
||||||
|
AND r.owner_id = public.current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY audit_events_insert ON audit.events
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT ON audit.events TO chattyness_app;
|
||||||
|
|
||||||
|
-- audit.login_history
|
||||||
|
ALTER TABLE audit.login_history ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY audit_login_history_own ON audit.login_history
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (user_id = public.current_user_id());
|
||||||
|
|
||||||
|
CREATE POLICY audit_login_history_admin ON audit.login_history
|
||||||
|
FOR SELECT TO chattyness_app
|
||||||
|
USING (public.is_server_admin());
|
||||||
|
|
||||||
|
CREATE POLICY audit_login_history_insert ON audit.login_history
|
||||||
|
FOR INSERT TO chattyness_app
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT ON audit.login_history TO chattyness_app;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
47
db/schema/tables/010_server.sql
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
-- Chattyness Server Schema Tables
|
||||||
|
-- PostgreSQL 18 with PostGIS
|
||||||
|
--
|
||||||
|
-- Server-wide configuration, global resources, and settings
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server Configuration (Singleton)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.config (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
welcome_message TEXT,
|
||||||
|
|
||||||
|
default_scene_bounds public.scene_bounds NOT NULL
|
||||||
|
DEFAULT ST_MakeEnvelope(0, 0, 800, 600, 0),
|
||||||
|
max_users_per_channel INTEGER NOT NULL DEFAULT 50
|
||||||
|
CHECK (max_users_per_channel > 0 AND max_users_per_channel <= 1000),
|
||||||
|
|
||||||
|
message_rate_limit INTEGER NOT NULL DEFAULT 10 CHECK (message_rate_limit > 0),
|
||||||
|
message_rate_window_seconds INTEGER NOT NULL DEFAULT 60 CHECK (message_rate_window_seconds > 0),
|
||||||
|
|
||||||
|
allow_guest_access BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
allow_user_uploads BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
require_email_verification BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT chk_server_config_singleton CHECK (id = '00000000-0000-0000-0000-000000000001'::UUID)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.config IS 'Server-wide configuration settings (singleton table)';
|
||||||
|
|
||||||
|
INSERT INTO server.config (id, name, description) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000001'::UUID,
|
||||||
|
'Chattyness Server',
|
||||||
|
'A 2D virtual chat world'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
782
db/schema/tables/020_auth.sql
Normal file
|
|
@ -0,0 +1,782 @@
|
||||||
|
-- Chattyness Auth Schema Tables
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- User authentication, accounts, and identity management
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- User Accounts
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
username auth.username NOT NULL,
|
||||||
|
email auth.email,
|
||||||
|
password_hash TEXT,
|
||||||
|
auth_provider auth.auth_provider NOT NULL DEFAULT 'local',
|
||||||
|
oauth_id TEXT,
|
||||||
|
|
||||||
|
display_name public.display_name NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url public.url,
|
||||||
|
|
||||||
|
reputation_tier server.reputation_tier NOT NULL DEFAULT 'member',
|
||||||
|
reputation_promoted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
status auth.account_status NOT NULL DEFAULT 'active',
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
force_pw_reset BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
last_seen_at TIMESTAMPTZ,
|
||||||
|
total_time_online_seconds BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
script_state JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- User tags for feature gating and access control
|
||||||
|
tags auth.user_tag[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_users_username UNIQUE (username),
|
||||||
|
CONSTRAINT uq_auth_users_email UNIQUE (email),
|
||||||
|
CONSTRAINT uq_auth_users_oauth UNIQUE (auth_provider, oauth_id),
|
||||||
|
CONSTRAINT chk_auth_users_password_or_oauth_or_guest
|
||||||
|
CHECK (password_hash IS NOT NULL OR oauth_id IS NOT NULL OR 'guest' = ANY(tags))
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.users IS 'User accounts and authentication';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_users_email ON auth.users (lower(email));
|
||||||
|
CREATE INDEX idx_auth_users_status ON auth.users (status);
|
||||||
|
CREATE INDEX idx_auth_users_reputation ON auth.users (reputation_tier);
|
||||||
|
CREATE INDEX idx_auth_users_last_seen ON auth.users (last_seen_at DESC NULLS LAST);
|
||||||
|
CREATE INDEX idx_auth_users_tags ON auth.users USING GIN (tags);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- User Sessions
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_sessions_token UNIQUE (token_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.sessions IS 'Active user sessions';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_sessions_user ON auth.sessions (user_id);
|
||||||
|
CREATE INDEX idx_auth_sessions_expires ON auth.sessions (expires_at);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Tower Sessions (tower-sessions crate)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.tower_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
expiry_date TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.tower_sessions IS 'Session storage for tower-sessions crate';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_tower_sessions_expiry ON auth.tower_sessions (expiry_date);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Friends List
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.friendships (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
friend_a UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
friend_b UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
initiated_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
is_accepted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_friendships UNIQUE (friend_a, friend_b),
|
||||||
|
CONSTRAINT chk_auth_friendships_ordered CHECK (friend_a < friend_b),
|
||||||
|
CONSTRAINT chk_auth_friendships_initiator CHECK (initiated_by = friend_a OR initiated_by = friend_b)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.friendships IS 'Friend relationships (normalized: friend_a < friend_b)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_friendships_friend_a ON auth.friendships (friend_a);
|
||||||
|
CREATE INDEX idx_auth_friendships_friend_b ON auth.friendships (friend_b);
|
||||||
|
CREATE INDEX idx_auth_friendships_pending ON auth.friendships (is_accepted) WHERE is_accepted = false;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Block List
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.blocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
blocker_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
blocked_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_blocks UNIQUE (blocker_id, blocked_id),
|
||||||
|
CONSTRAINT chk_auth_blocks_not_self CHECK (blocker_id != blocked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.blocks IS 'User block list';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_blocks_blocker ON auth.blocks (blocker_id);
|
||||||
|
CREATE INDEX idx_auth_blocks_blocked ON auth.blocks (blocked_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Mute List
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.mutes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
muter_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
muted_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_mutes UNIQUE (muter_id, muted_id),
|
||||||
|
CONSTRAINT chk_auth_mutes_not_self CHECK (muter_id != muted_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.mutes IS 'User mute list';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_mutes_muter ON auth.mutes (muter_id);
|
||||||
|
CREATE INDEX idx_auth_mutes_muted ON auth.mutes (muted_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Password Reset Tokens
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.password_resets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_password_resets_token UNIQUE (token_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.password_resets IS 'Password reset tokens';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_password_resets_user ON auth.password_resets (user_id);
|
||||||
|
CREATE INDEX idx_auth_password_resets_expires ON auth.password_resets (expires_at) WHERE used_at IS NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Email Verification Tokens
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.email_verifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
email auth.email NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_email_verifications_token UNIQUE (token_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.email_verifications IS 'Email verification tokens';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_email_verifications_user ON auth.email_verifications (user_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- User Scripts
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.user_scripts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
slug public.slug NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
state JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_user_scripts_slug UNIQUE (user_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.user_scripts IS 'Per-user Rhai scripts';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_user_scripts_user ON auth.user_scripts (user_id);
|
||||||
|
CREATE INDEX idx_auth_user_scripts_enabled ON auth.user_scripts (user_id, is_enabled) WHERE is_enabled = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server Staff (created here since it references auth.users)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.staff (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
role server.server_role NOT NULL,
|
||||||
|
appointed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
appointed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_server_staff_user UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.staff IS 'Server-level administrative staff';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server Prop Library (Global Props)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.props (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
slug public.slug NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
asset_path public.asset_path NOT NULL,
|
||||||
|
thumbnail_path public.asset_path,
|
||||||
|
|
||||||
|
-- Default avatar positioning (content layer OR emotion layer, mutually exclusive)
|
||||||
|
default_layer server.avatar_layer,
|
||||||
|
default_emotion server.emotion_state,
|
||||||
|
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
|
||||||
|
|
||||||
|
is_unique BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_transferable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_portable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_droppable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
available_from TIMESTAMPTZ,
|
||||||
|
available_until TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_server_props_slug UNIQUE (slug),
|
||||||
|
CONSTRAINT chk_server_props_availability CHECK (
|
||||||
|
available_from IS NULL OR available_until IS NULL OR available_from < available_until
|
||||||
|
),
|
||||||
|
-- Props can be: non-avatar (all NULL), content layer, OR emotion layer (mutually exclusive)
|
||||||
|
CONSTRAINT chk_server_props_positioning CHECK (
|
||||||
|
-- Nothing set (non-avatar prop)
|
||||||
|
(default_layer IS NULL AND default_emotion IS NULL AND default_position IS NULL) OR
|
||||||
|
-- Content layer prop (skin/clothes/accessories at position 0-8)
|
||||||
|
(default_layer IS NOT NULL AND default_emotion IS NULL AND default_position IS NOT NULL) OR
|
||||||
|
-- Emotion layer prop (neutral/happy/sad/etc at position 0-8)
|
||||||
|
(default_layer IS NULL AND default_emotion IS NOT NULL AND default_position IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.props IS 'Global prop library (64x64 pixels, center-anchored)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_props_tags ON server.props USING GIN (tags);
|
||||||
|
CREATE INDEX idx_server_props_active ON server.props (is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Audio Library
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.audio (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
slug public.slug NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category server.audio_category NOT NULL,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
asset_path public.asset_path NOT NULL,
|
||||||
|
duration_seconds REAL NOT NULL CHECK (duration_seconds > 0),
|
||||||
|
is_loopable BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_server_audio_slug UNIQUE (slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.audio IS 'Global audio library';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_audio_category ON server.audio (category);
|
||||||
|
CREATE INDEX idx_server_audio_tags ON server.audio USING GIN (tags);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Reserved Names
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.reserved_names (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
name_type server.reserved_name_type NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
|
||||||
|
reserved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.reserved_names IS 'Names reserved from use by users';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_server_reserved_names ON server.reserved_names (lower(name), name_type);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server Scripts
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.scripts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
slug public.slug NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
state JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
run_on_user_login BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
run_on_user_logout BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
run_on_registration BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
run_on_server_shutdown BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_server_scripts_slug UNIQUE (slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.scripts IS 'Server-wide Rhai scripts';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_scripts_enabled ON server.scripts (is_enabled) WHERE is_enabled = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- User Inventory (moved from props.inventory)
|
||||||
|
-- =============================================================================
|
||||||
|
-- User inventory stores props owned by users (worn on avatar or in bag).
|
||||||
|
-- Inventory items reference a source: server prop, realm prop, or user upload.
|
||||||
|
-- The denormalized prop_name/prop_asset_path are cached at acquisition time.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.inventory (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Source of the prop (at least one required)
|
||||||
|
server_prop_id UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
||||||
|
realm_prop_id UUID, -- FK added in 030_realm.sql
|
||||||
|
upload_id UUID, -- Future: user uploads
|
||||||
|
|
||||||
|
-- Denormalized display info (cached at acquisition)
|
||||||
|
prop_name TEXT NOT NULL,
|
||||||
|
prop_asset_path public.asset_path NOT NULL,
|
||||||
|
layer server.avatar_layer,
|
||||||
|
position SMALLINT CHECK (position IS NULL OR position BETWEEN 0 AND 8),
|
||||||
|
|
||||||
|
-- Provenance chain for tracking history
|
||||||
|
provenance JSONB NOT NULL DEFAULT '[]',
|
||||||
|
origin server.prop_origin NOT NULL,
|
||||||
|
|
||||||
|
-- Behavioral flags (cached from source at acquisition)
|
||||||
|
is_transferable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_portable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_droppable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- At least one source must be present
|
||||||
|
CONSTRAINT chk_auth_inventory_has_source CHECK (
|
||||||
|
server_prop_id IS NOT NULL OR realm_prop_id IS NOT NULL OR upload_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.inventory IS 'User-owned props (denormalized for performance)';
|
||||||
|
COMMENT ON COLUMN auth.inventory.provenance IS 'Array of {from_user, timestamp, method} objects';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_inventory_user ON auth.inventory (user_id);
|
||||||
|
CREATE INDEX idx_auth_inventory_server_prop ON auth.inventory (server_prop_id)
|
||||||
|
WHERE server_prop_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_auth_inventory_realm_prop ON auth.inventory (realm_prop_id)
|
||||||
|
WHERE realm_prop_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- User Avatars (moved from props.avatars)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Avatar configurations per user (up to 10 slots: 0-9).
|
||||||
|
-- Uses 15 columns per layer, 135 total for the 9-position grid system.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.avatars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
slot_number SMALLINT NOT NULL CHECK (slot_number >= 0 AND slot_number <= 9),
|
||||||
|
|
||||||
|
name public.display_name,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
last_emotion SMALLINT NOT NULL DEFAULT 0 CHECK (last_emotion >= 0 AND last_emotion <= 11),
|
||||||
|
|
||||||
|
-- Content layers: skin (3), clothes (3), accessories (3) = 9 layers x 9 positions = 81 potential slots
|
||||||
|
-- But we use 3 layers x 9 positions = 27 content slots
|
||||||
|
l_skin_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_skin_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
l_clothes_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_clothes_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
l_accessories_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
l_accessories_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Emotion layers: 12 emotions x 9 positions = 108 slots
|
||||||
|
e_neutral_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_neutral_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_happy_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_happy_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_sad_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sad_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_angry_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_angry_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_surprised_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_surprised_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_thinking_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_thinking_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_laughing_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_laughing_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_crying_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_crying_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_love_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_love_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_confused_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_confused_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_sleeping_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_sleeping_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
e_wink_0 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_1 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_2 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_3 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_4 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_5 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_6 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_7 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
e_wink_8 UUID REFERENCES auth.inventory(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_avatars_slot UNIQUE (user_id, slot_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.avatars IS 'User avatar configurations with 135 prop slots';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_avatars_user ON auth.avatars (user_id);
|
||||||
|
CREATE INDEX idx_auth_avatars_default ON auth.avatars (user_id, is_default) WHERE is_default = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Active Avatars (moved from props.active_avatars)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Tracks which avatar a user is currently using in each realm.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE auth.active_avatars (
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
realm_id UUID NOT NULL, -- FK added in 030_realm.sql after realm.realms exists
|
||||||
|
avatar_id UUID NOT NULL REFERENCES auth.avatars(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
current_emotion SMALLINT NOT NULL DEFAULT 0 CHECK (current_emotion >= 0 AND current_emotion <= 11),
|
||||||
|
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
PRIMARY KEY (user_id, realm_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.active_avatars IS 'Current avatar per user per realm';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server-Level Moderation: IP Bans
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.ip_bans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
ip_address INET NOT NULL,
|
||||||
|
ip_range CIDR, -- Optional CIDR range for subnet bans
|
||||||
|
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
evidence JSONB NOT NULL DEFAULT '[]',
|
||||||
|
|
||||||
|
banned_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMPTZ, -- NULL = permanent
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_server_ip_bans_address UNIQUE (ip_address)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.ip_bans IS 'Server-wide IP address bans';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_ip_bans_range ON server.ip_bans USING GIST (ip_range inet_ops)
|
||||||
|
WHERE ip_range IS NOT NULL;
|
||||||
|
CREATE INDEX idx_server_ip_bans_expires ON server.ip_bans (expires_at)
|
||||||
|
WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server-Level Moderation: User Bans
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.bans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
evidence JSONB NOT NULL DEFAULT '[]',
|
||||||
|
|
||||||
|
banned_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMPTZ, -- NULL = permanent
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
unbanned_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
unbanned_at TIMESTAMPTZ,
|
||||||
|
unban_reason TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.bans IS 'Server-wide user bans';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_bans_user ON server.bans (user_id);
|
||||||
|
CREATE INDEX idx_server_bans_active ON server.bans (user_id, is_active) WHERE is_active = true;
|
||||||
|
CREATE INDEX idx_server_bans_expires ON server.bans (expires_at) WHERE expires_at IS NOT NULL AND is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server-Level Moderation: Server Mutes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.mutes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
|
||||||
|
muted_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMPTZ, -- NULL = permanent
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
unmuted_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
unmuted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.mutes IS 'Server-wide user mutes (cannot send messages anywhere)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_mutes_user ON server.mutes (user_id);
|
||||||
|
CREATE INDEX idx_server_mutes_active ON server.mutes (user_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server-Level Moderation: Content Filters
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.content_filters (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
is_regex BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_case_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
action server.filter_action NOT NULL DEFAULT 'block',
|
||||||
|
replacement TEXT,
|
||||||
|
|
||||||
|
reason TEXT,
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.content_filters IS 'Server-wide content filtering rules';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_content_filters_active ON server.content_filters (is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server-Level Moderation: Action Log
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE server.moderation_actions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
action_type server.action_type NOT NULL,
|
||||||
|
|
||||||
|
target_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
moderator_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
evidence JSONB NOT NULL DEFAULT '[]',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE server.moderation_actions IS 'Log of server-level moderation actions';
|
||||||
|
|
||||||
|
CREATE INDEX idx_server_moderation_actions_target ON server.moderation_actions (target_user_id);
|
||||||
|
CREATE INDEX idx_server_moderation_actions_moderator ON server.moderation_actions (moderator_id);
|
||||||
|
CREATE INDEX idx_server_moderation_actions_type ON server.moderation_actions (action_type);
|
||||||
|
CREATE INDEX idx_server_moderation_actions_created ON server.moderation_actions (created_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
422
db/schema/tables/030_realm.sql
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
-- Chattyness Realm Schema Tables
|
||||||
|
-- PostgreSQL 18 with PostGIS
|
||||||
|
--
|
||||||
|
-- Realms, scenes, memberships, realm props, and realm-level moderation
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realms
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.realms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
slug public.slug NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
tagline TEXT,
|
||||||
|
|
||||||
|
owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
|
||||||
|
|
||||||
|
privacy realm.realm_privacy NOT NULL DEFAULT 'public',
|
||||||
|
is_nsfw BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
min_reputation_tier server.reputation_tier NOT NULL DEFAULT 'guest',
|
||||||
|
|
||||||
|
theme_color public.hex_color,
|
||||||
|
banner_image_path public.asset_path,
|
||||||
|
thumbnail_path public.asset_path,
|
||||||
|
|
||||||
|
max_users INTEGER NOT NULL DEFAULT 100 CHECK (max_users > 0 AND max_users <= 10000),
|
||||||
|
allow_guest_access BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
default_scene_id UUID,
|
||||||
|
|
||||||
|
member_count INTEGER NOT NULL DEFAULT 0 CHECK (member_count >= 0),
|
||||||
|
current_user_count INTEGER NOT NULL DEFAULT 0 CHECK (current_user_count >= 0),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_realm_realms_slug UNIQUE (slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.realms IS 'Themed virtual spaces';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_realms_owner ON realm.realms (owner_id);
|
||||||
|
CREATE INDEX idx_realm_realms_privacy ON realm.realms (privacy);
|
||||||
|
CREATE INDEX idx_realm_realms_public ON realm.realms (privacy, is_nsfw) WHERE privacy = 'public';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Scenes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.scenes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
slug public.slug NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
background_image_path public.asset_path,
|
||||||
|
background_image_url public.url,
|
||||||
|
background_color public.hex_color DEFAULT '#1a1a2e',
|
||||||
|
bounds public.scene_bounds NOT NULL DEFAULT ST_MakeEnvelope(0, 0, 800, 600, 0),
|
||||||
|
dimension_mode realm.dimension_mode NOT NULL DEFAULT 'fixed',
|
||||||
|
|
||||||
|
ambient_audio_id UUID REFERENCES server.audio(id) ON DELETE SET NULL,
|
||||||
|
ambient_volume public.percentage DEFAULT 0.5,
|
||||||
|
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
is_entry_point BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_hidden BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_realm_scenes_slug UNIQUE (realm_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.scenes IS 'Rooms within a realm';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_scenes_realm ON realm.scenes (realm_id);
|
||||||
|
CREATE INDEX idx_realm_scenes_realm_order ON realm.scenes (realm_id, sort_order);
|
||||||
|
|
||||||
|
-- Add FK for default_scene_id
|
||||||
|
ALTER TABLE realm.realms
|
||||||
|
ADD CONSTRAINT fk_realm_realms_default_scene
|
||||||
|
FOREIGN KEY (default_scene_id) REFERENCES realm.scenes(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Guest Sessions (created here since it references realm tables)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Note: current_instance_id FK is added in 045_scene.sql after scene.instances exists
|
||||||
|
|
||||||
|
CREATE TABLE auth.guest_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
guest_name public.display_name NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
|
||||||
|
current_realm_id UUID REFERENCES realm.realms(id) ON DELETE SET NULL,
|
||||||
|
current_instance_id UUID, -- FK added in 045_scene.sql
|
||||||
|
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_guest_sessions_token UNIQUE (token_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.guest_sessions IS 'Anonymous guest sessions';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_guest_sessions_expires ON auth.guest_sessions (expires_at);
|
||||||
|
CREATE INDEX idx_auth_guest_sessions_ip ON auth.guest_sessions (ip_address);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm Memberships
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.memberships (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
nickname public.display_name,
|
||||||
|
|
||||||
|
role realm.realm_role NOT NULL DEFAULT 'member',
|
||||||
|
role_granted_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
role_granted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
exp_score BIGINT NOT NULL DEFAULT 0 CHECK (exp_score >= 0),
|
||||||
|
|
||||||
|
last_scene_id UUID REFERENCES realm.scenes(id) ON DELETE SET NULL,
|
||||||
|
last_position public.virtual_point,
|
||||||
|
|
||||||
|
last_visited_at TIMESTAMPTZ,
|
||||||
|
total_time_seconds BIGINT NOT NULL DEFAULT 0 CHECK (total_time_seconds >= 0),
|
||||||
|
|
||||||
|
script_state JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_realm_memberships UNIQUE (realm_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.memberships IS 'User membership within a realm';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_realm_memberships_nickname ON realm.memberships (realm_id, lower(nickname)) WHERE nickname IS NOT NULL;
|
||||||
|
CREATE INDEX idx_realm_memberships_realm ON realm.memberships (realm_id);
|
||||||
|
CREATE INDEX idx_realm_memberships_user ON realm.memberships (user_id);
|
||||||
|
CREATE INDEX idx_realm_memberships_role ON realm.memberships (realm_id, role) WHERE role IN ('owner', 'moderator', 'builder');
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm Scripts
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.realm_scripts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
state JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
run_on_realm_enter BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.realm_scripts IS 'Rhai scripts for realms';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_realm_scripts_realm ON realm.realm_scripts (realm_id);
|
||||||
|
CREATE INDEX idx_realm_realm_scripts_enabled ON realm.realm_scripts (realm_id, is_enabled) WHERE is_enabled = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm Prop Library (moved from props.realm_props)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Custom props specific to a realm (can only be used in that realm)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.props (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
slug public.slug NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
asset_path public.asset_path NOT NULL,
|
||||||
|
thumbnail_path public.asset_path,
|
||||||
|
|
||||||
|
-- Default avatar positioning
|
||||||
|
default_layer server.avatar_layer,
|
||||||
|
default_emotion server.emotion_state,
|
||||||
|
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
|
||||||
|
|
||||||
|
is_unique BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_transferable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_droppable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
available_from TIMESTAMPTZ,
|
||||||
|
available_until TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_realm_props_slug UNIQUE (realm_id, slug),
|
||||||
|
CONSTRAINT chk_realm_props_availability CHECK (
|
||||||
|
available_from IS NULL OR available_until IS NULL OR available_from < available_until
|
||||||
|
),
|
||||||
|
-- Props can be: non-avatar (all NULL), content layer, OR emotion layer
|
||||||
|
CONSTRAINT chk_realm_props_positioning CHECK (
|
||||||
|
(default_layer IS NULL AND default_emotion IS NULL AND default_position IS NULL) OR
|
||||||
|
(default_layer IS NOT NULL AND default_emotion IS NULL AND default_position IS NOT NULL) OR
|
||||||
|
(default_layer IS NULL AND default_emotion IS NOT NULL AND default_position IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.props IS 'Realm-specific prop library';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_props_realm ON realm.props (realm_id);
|
||||||
|
CREATE INDEX idx_realm_props_tags ON realm.props USING GIN (tags);
|
||||||
|
CREATE INDEX idx_realm_props_active ON realm.props (realm_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Add Foreign Keys to auth tables (now that realm.realms and realm.props exist)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Add FK for auth.inventory.realm_prop_id
|
||||||
|
ALTER TABLE auth.inventory
|
||||||
|
ADD CONSTRAINT fk_auth_inventory_realm_prop
|
||||||
|
FOREIGN KEY (realm_prop_id) REFERENCES realm.props(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add FK for auth.active_avatars.realm_id
|
||||||
|
ALTER TABLE auth.active_avatars
|
||||||
|
ADD CONSTRAINT fk_auth_active_avatars_realm
|
||||||
|
FOREIGN KEY (realm_id) REFERENCES realm.realms(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm-Level Moderation: Reports
|
||||||
|
-- =============================================================================
|
||||||
|
-- Reports are always realm-scoped (but server admins can resolve them)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
reporter_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
reported_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
evidence JSONB NOT NULL DEFAULT '[]',
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
scene_id UUID REFERENCES realm.scenes(id) ON DELETE SET NULL,
|
||||||
|
message_id UUID, -- FK added when chat schema loads
|
||||||
|
|
||||||
|
status server.report_status NOT NULL DEFAULT 'pending',
|
||||||
|
resolved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
resolution_notes TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT chk_realm_reports_not_self CHECK (reporter_id != reported_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.reports IS 'User reports within a realm';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_reports_realm ON realm.reports (realm_id);
|
||||||
|
CREATE INDEX idx_realm_reports_reporter ON realm.reports (reporter_id);
|
||||||
|
CREATE INDEX idx_realm_reports_reported ON realm.reports (reported_user_id);
|
||||||
|
CREATE INDEX idx_realm_reports_status ON realm.reports (realm_id, status) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm-Level Moderation: Realm Bans
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.bans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
evidence JSONB NOT NULL DEFAULT '[]',
|
||||||
|
|
||||||
|
banned_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMPTZ, -- NULL = permanent
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
unbanned_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
unbanned_at TIMESTAMPTZ,
|
||||||
|
unban_reason TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_realm_bans_active UNIQUE (realm_id, user_id) -- Only one active ban per user per realm
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.bans IS 'Realm-specific user bans';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_bans_realm ON realm.bans (realm_id);
|
||||||
|
CREATE INDEX idx_realm_bans_user ON realm.bans (user_id);
|
||||||
|
CREATE INDEX idx_realm_bans_active ON realm.bans (realm_id, user_id, is_active) WHERE is_active = true;
|
||||||
|
CREATE INDEX idx_realm_bans_expires ON realm.bans (expires_at) WHERE expires_at IS NOT NULL AND is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm-Level Moderation: Realm Mutes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.mutes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
|
||||||
|
muted_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMPTZ, -- NULL = permanent
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
unmuted_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
unmuted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.mutes IS 'Realm-specific user mutes (cannot send messages in this realm)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_mutes_realm ON realm.mutes (realm_id);
|
||||||
|
CREATE INDEX idx_realm_mutes_user ON realm.mutes (user_id);
|
||||||
|
CREATE INDEX idx_realm_mutes_active ON realm.mutes (realm_id, user_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm-Level Moderation: Content Filters
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.content_filters (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
is_regex BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_case_sensitive BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
action server.filter_action NOT NULL DEFAULT 'block',
|
||||||
|
replacement TEXT,
|
||||||
|
|
||||||
|
reason TEXT,
|
||||||
|
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.content_filters IS 'Realm-specific content filtering rules';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_content_filters_realm ON realm.content_filters (realm_id);
|
||||||
|
CREATE INDEX idx_realm_content_filters_active ON realm.content_filters (realm_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm-Level Moderation: Action Log
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE realm.moderation_actions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
action_type server.action_type NOT NULL,
|
||||||
|
|
||||||
|
target_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
moderator_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
evidence JSONB NOT NULL DEFAULT '[]',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE realm.moderation_actions IS 'Log of realm-level moderation actions';
|
||||||
|
|
||||||
|
CREATE INDEX idx_realm_moderation_actions_realm ON realm.moderation_actions (realm_id);
|
||||||
|
CREATE INDEX idx_realm_moderation_actions_target ON realm.moderation_actions (target_user_id);
|
||||||
|
CREATE INDEX idx_realm_moderation_actions_moderator ON realm.moderation_actions (moderator_id);
|
||||||
|
CREATE INDEX idx_realm_moderation_actions_type ON realm.moderation_actions (realm_id, action_type);
|
||||||
|
CREATE INDEX idx_realm_moderation_actions_created ON realm.moderation_actions (realm_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
306
db/schema/tables/045_scene.sql
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
-- Chattyness Scene Schema Tables
|
||||||
|
-- PostgreSQL 18 with PostGIS
|
||||||
|
--
|
||||||
|
-- Scene-level runtime state: instances, members, spots, loose props, decorations
|
||||||
|
-- Load via: psql -f schema/tables/045_scene.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Instances (renamed from realm.channels)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Instances are ephemeral scene rooms where users can interact.
|
||||||
|
-- Each scene can have multiple instances (public default + private rooms).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.instances (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
scene_id UUID NOT NULL REFERENCES realm.scenes(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
instance_type scene.instance_type NOT NULL DEFAULT 'public',
|
||||||
|
name public.display_name,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
max_users INTEGER NOT NULL DEFAULT 50 CHECK (max_users > 0 AND max_users <= 1000),
|
||||||
|
current_user_count INTEGER NOT NULL DEFAULT 0 CHECK (current_user_count >= 0),
|
||||||
|
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.instances IS 'Ephemeral scene rooms where users interact';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_instances_scene ON scene.instances (scene_id);
|
||||||
|
CREATE INDEX idx_scene_instances_type ON scene.instances (scene_id, instance_type);
|
||||||
|
CREATE INDEX idx_scene_instances_expires ON scene.instances (expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Add FK from auth.guest_sessions to scene.instances
|
||||||
|
-- =============================================================================
|
||||||
|
-- guest_sessions.current_instance_id was added without FK in 030_realm.sql
|
||||||
|
-- Now we can add the constraint since scene.instances exists
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE auth.guest_sessions
|
||||||
|
ADD CONSTRAINT fk_auth_guest_sessions_instance
|
||||||
|
FOREIGN KEY (current_instance_id) REFERENCES scene.instances(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Instance Members (renamed from realm.channel_members)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Users currently present in an instance with their positions.
|
||||||
|
-- Note: instance_id is actually scene_id in this system (scenes are used directly as instances).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.instance_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
instance_id UUID NOT NULL REFERENCES realm.scenes(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
guest_session_id UUID REFERENCES auth.guest_sessions(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
position public.virtual_point NOT NULL DEFAULT ST_SetSRID(ST_MakePoint(400, 300), 0),
|
||||||
|
|
||||||
|
facing_direction SMALLINT NOT NULL DEFAULT 0 CHECK (facing_direction >= 0 AND facing_direction < 360),
|
||||||
|
is_moving BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_afk BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT chk_scene_instance_members_user_or_guest CHECK (
|
||||||
|
(user_id IS NOT NULL AND guest_session_id IS NULL) OR
|
||||||
|
(user_id IS NULL AND guest_session_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT uq_scene_instance_members_user UNIQUE (instance_id, user_id),
|
||||||
|
CONSTRAINT uq_scene_instance_members_guest UNIQUE (instance_id, guest_session_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.instance_members IS 'Users in an instance with positions';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_instance_members_instance ON scene.instance_members (instance_id);
|
||||||
|
CREATE INDEX idx_scene_instance_members_user ON scene.instance_members (user_id) WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_scene_instance_members_guest ON scene.instance_members (guest_session_id) WHERE guest_session_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_scene_instance_members_position ON scene.instance_members USING GIST (position);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Instance Invites (renamed from realm.channel_invites)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.instance_invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
instance_id UUID NOT NULL REFERENCES scene.instances(id) ON DELETE CASCADE,
|
||||||
|
invited_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
invited_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
declined_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_scene_instance_invites UNIQUE (instance_id, invited_user_id),
|
||||||
|
CONSTRAINT chk_scene_instance_invites_not_self CHECK (invited_by != invited_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.instance_invites IS 'Private instance invitations';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_instance_invites_instance ON scene.instance_invites (instance_id);
|
||||||
|
CREATE INDEX idx_scene_instance_invites_user ON scene.instance_invites (invited_user_id);
|
||||||
|
CREATE INDEX idx_scene_instance_invites_pending ON scene.instance_invites (invited_user_id, expires_at)
|
||||||
|
WHERE accepted_at IS NULL AND declined_at IS NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Spots (moved from realm.spots)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.spots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
scene_id UUID NOT NULL REFERENCES realm.scenes(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
name TEXT,
|
||||||
|
slug public.slug,
|
||||||
|
|
||||||
|
region GEOMETRY(GEOMETRY, 0) NOT NULL,
|
||||||
|
|
||||||
|
spot_type scene.spot_type NOT NULL DEFAULT 'normal',
|
||||||
|
|
||||||
|
destination_scene_id UUID REFERENCES realm.scenes(id) ON DELETE SET NULL,
|
||||||
|
destination_position GEOMETRY(POINT, 0),
|
||||||
|
|
||||||
|
current_state SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
is_visible BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_scene_spots_slug UNIQUE (scene_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.spots IS 'Interactive regions in scenes';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_spots_scene ON scene.spots (scene_id);
|
||||||
|
CREATE INDEX idx_scene_spots_region ON scene.spots USING GIST (region);
|
||||||
|
CREATE INDEX idx_scene_spots_active ON scene.spots (scene_id, is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Spot States (moved from realm.spot_states)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.spot_states (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
spot_id UUID NOT NULL REFERENCES scene.spots(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
state_number SMALLINT NOT NULL,
|
||||||
|
|
||||||
|
asset_path public.asset_path,
|
||||||
|
offset_x REAL NOT NULL DEFAULT 0,
|
||||||
|
offset_y REAL NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
audio_id UUID REFERENCES server.audio(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
CONSTRAINT uq_scene_spot_states UNIQUE (spot_id, state_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.spot_states IS 'Visual configurations for spot states';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_spot_states_spot ON scene.spot_states (spot_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Scene Scripts (moved from realm.scene_scripts)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.scripts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
scene_id UUID NOT NULL REFERENCES realm.scenes(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
name public.nonempty_text NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
state JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
run_on_enter BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
handle_private_instances BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.scripts IS 'Rhai scripts for scenes';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_scripts_scene ON scene.scripts (scene_id);
|
||||||
|
CREATE INDEX idx_scene_scripts_enabled ON scene.scripts (scene_id, is_enabled) WHERE is_enabled = true;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Loose Props (moved from props.loose_props)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Props that exist at a position in an instance, not worn by anyone.
|
||||||
|
-- Can be picked up by users.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.loose_props (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
instance_id UUID NOT NULL REFERENCES scene.instances(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Source of the prop (either server or realm library)
|
||||||
|
server_prop_id UUID REFERENCES server.props(id) ON DELETE CASCADE,
|
||||||
|
realm_prop_id UUID REFERENCES realm.props(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Position in scene (PostGIS point, SRID 0)
|
||||||
|
position public.virtual_point NOT NULL,
|
||||||
|
|
||||||
|
-- Who dropped it (NULL = spawned by system/script)
|
||||||
|
dropped_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Auto-decay
|
||||||
|
expires_at TIMESTAMPTZ, -- NULL = permanent
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- Must reference exactly one source
|
||||||
|
CONSTRAINT chk_scene_loose_props_source CHECK (
|
||||||
|
(server_prop_id IS NOT NULL AND realm_prop_id IS NULL) OR
|
||||||
|
(server_prop_id IS NULL AND realm_prop_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.loose_props IS 'Props dropped in instances that can be picked up';
|
||||||
|
COMMENT ON COLUMN scene.loose_props.position IS 'Location in scene as PostGIS point (SRID 0)';
|
||||||
|
COMMENT ON COLUMN scene.loose_props.expires_at IS 'When prop auto-decays (NULL = permanent)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_loose_props_instance ON scene.loose_props (instance_id);
|
||||||
|
CREATE INDEX idx_scene_loose_props_expires ON scene.loose_props (expires_at)
|
||||||
|
WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Spatial index for finding props near a position
|
||||||
|
CREATE INDEX idx_scene_loose_props_position ON scene.loose_props
|
||||||
|
USING GIST (position);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Scene Decorations (moved from props.scene_decorations)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Props placed in scenes by builders/owners as permanent decoration.
|
||||||
|
-- Can optionally be copied by users into their inventory.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE scene.decorations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
scene_id UUID NOT NULL REFERENCES realm.scenes(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Source of the prop
|
||||||
|
server_prop_id UUID REFERENCES server.props(id) ON DELETE CASCADE,
|
||||||
|
realm_prop_id UUID REFERENCES realm.props(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Position and display
|
||||||
|
position public.virtual_point NOT NULL,
|
||||||
|
z_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scale REAL NOT NULL DEFAULT 1.0 CHECK (scale > 0 AND scale <= 10),
|
||||||
|
rotation SMALLINT NOT NULL DEFAULT 0 CHECK (rotation >= 0 AND rotation < 360),
|
||||||
|
opacity public.percentage NOT NULL DEFAULT 1.0,
|
||||||
|
|
||||||
|
-- Interaction
|
||||||
|
is_copyable BOOLEAN NOT NULL DEFAULT false, -- Users can copy to inventory
|
||||||
|
click_action JSONB, -- Optional click behavior
|
||||||
|
|
||||||
|
-- Management
|
||||||
|
placed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- Must reference exactly one source
|
||||||
|
CONSTRAINT chk_scene_decorations_source CHECK (
|
||||||
|
(server_prop_id IS NOT NULL AND realm_prop_id IS NULL) OR
|
||||||
|
(server_prop_id IS NULL AND realm_prop_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scene.decorations IS 'Permanent prop decorations placed in scenes';
|
||||||
|
COMMENT ON COLUMN scene.decorations.is_copyable IS 'If true, users can copy this prop to their inventory';
|
||||||
|
COMMENT ON COLUMN scene.decorations.click_action IS 'Optional JSON action config for click behavior';
|
||||||
|
|
||||||
|
CREATE INDEX idx_scene_decorations_scene ON scene.decorations (scene_id);
|
||||||
|
|
||||||
|
-- Spatial index for rendering props in view
|
||||||
|
CREATE INDEX idx_scene_decorations_position ON scene.decorations
|
||||||
|
USING GIST (position);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
174
db/schema/tables/050_chat.sql
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
-- Chattyness Chat Schema Tables
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Messages, whispers, shouts, and reactions
|
||||||
|
-- Load via: psql -f schema/tables/050_chat.sql
|
||||||
|
--
|
||||||
|
-- NOTE: This table is designed for future partitioning by created_at.
|
||||||
|
-- When message volume grows, convert to a partitioned table:
|
||||||
|
-- 1. Rename chat.messages to chat.messages_old
|
||||||
|
-- 2. Create new partitioned chat.messages with PARTITION BY RANGE (created_at)
|
||||||
|
-- 3. Create monthly partitions (e.g., chat.messages_2025_01)
|
||||||
|
-- 4. Migrate data from old table
|
||||||
|
-- 5. Consider pg_partman extension for automatic partition management
|
||||||
|
-- 6. Add archive table for long-term storage of old partitions
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Instance Messages (Scene-wide chat)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Messages visible to everyone in an instance.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE chat.messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
instance_id UUID NOT NULL REFERENCES scene.instances(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Sender (either user or guest)
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
guest_session_id UUID REFERENCES auth.guest_sessions(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Cached sender info (in case account deleted)
|
||||||
|
sender_name public.display_name NOT NULL,
|
||||||
|
|
||||||
|
-- Message content
|
||||||
|
message_type chat.message_type NOT NULL DEFAULT 'normal',
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Position when message was sent (for speech bubble placement)
|
||||||
|
position public.virtual_point,
|
||||||
|
|
||||||
|
-- Reply threading
|
||||||
|
reply_to_id UUID REFERENCES chat.messages(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Moderation
|
||||||
|
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
deleted_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- Either user_id or guest_session_id must be set
|
||||||
|
CONSTRAINT chk_chat_messages_sender CHECK (
|
||||||
|
(user_id IS NOT NULL AND guest_session_id IS NULL) OR
|
||||||
|
(user_id IS NULL AND guest_session_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE chat.messages IS 'Instance messages (design supports future time-based partitioning)';
|
||||||
|
COMMENT ON COLUMN chat.messages.position IS 'Sender position when message sent (for speech bubbles)';
|
||||||
|
COMMENT ON COLUMN chat.messages.reply_to_id IS 'ID of message being replied to';
|
||||||
|
|
||||||
|
CREATE INDEX idx_chat_messages_instance_time ON chat.messages (instance_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_chat_messages_user ON chat.messages (user_id, created_at DESC)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_chat_messages_reply ON chat.messages (reply_to_id)
|
||||||
|
WHERE reply_to_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_chat_messages_created ON chat.messages (created_at DESC);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Shouts (Server-wide broadcasts)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Messages broadcast to all users on the server.
|
||||||
|
-- Typically limited to admins or special events.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE chat.shouts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
sender_name public.display_name NOT NULL,
|
||||||
|
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Targeting
|
||||||
|
target_realm_id UUID REFERENCES realm.realms(id) ON DELETE CASCADE, -- NULL = all realms
|
||||||
|
|
||||||
|
-- TTL for display
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE chat.shouts IS 'Server-wide or realm-wide broadcast messages';
|
||||||
|
COMMENT ON COLUMN chat.shouts.target_realm_id IS 'NULL = broadcast to all realms';
|
||||||
|
|
||||||
|
CREATE INDEX idx_chat_shouts_time ON chat.shouts (created_at DESC);
|
||||||
|
CREATE INDEX idx_chat_shouts_realm ON chat.shouts (target_realm_id, created_at DESC)
|
||||||
|
WHERE target_realm_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Whispers (Private Direct Messages)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Private messages between two users, cross-realm capable.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE chat.whispers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Sender and recipient
|
||||||
|
sender_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
recipient_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Read status
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Moderation
|
||||||
|
is_deleted_by_sender BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_deleted_by_recipient BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT chk_chat_whispers_not_self CHECK (sender_id != recipient_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE chat.whispers IS 'Private direct messages between users';
|
||||||
|
|
||||||
|
CREATE INDEX idx_chat_whispers_sender ON chat.whispers (sender_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_chat_whispers_recipient ON chat.whispers (recipient_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_chat_whispers_unread ON chat.whispers (recipient_id, read_at)
|
||||||
|
WHERE read_at IS NULL;
|
||||||
|
|
||||||
|
-- Composite index for conversation view between two users
|
||||||
|
CREATE INDEX idx_chat_whispers_conversation ON chat.whispers (
|
||||||
|
LEAST(sender_id, recipient_id),
|
||||||
|
GREATEST(sender_id, recipient_id),
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Message Reactions
|
||||||
|
-- =============================================================================
|
||||||
|
-- Emoji reactions to channel messages.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE chat.reactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
message_id UUID NOT NULL REFERENCES chat.messages(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Reactor
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Reaction (emoji or predefined reaction code)
|
||||||
|
reaction TEXT NOT NULL CHECK (length(reaction) <= 32),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- One reaction type per user per message
|
||||||
|
CONSTRAINT uq_chat_reactions UNIQUE (message_id, user_id, reaction)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE chat.reactions IS 'Emoji reactions to channel messages';
|
||||||
|
|
||||||
|
CREATE INDEX idx_chat_reactions_message ON chat.reactions (message_id);
|
||||||
|
CREATE INDEX idx_chat_reactions_user ON chat.reactions (user_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
186
db/schema/tables/080_audit.sql
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
-- Chattyness Audit Schema Tables
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Audit trails and activity logging
|
||||||
|
-- Load via: psql -f schema/tables/080_audit.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Audit Event Types
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TYPE audit.event_category AS ENUM (
|
||||||
|
'auth', -- Login, logout, password changes
|
||||||
|
'account', -- Profile updates, settings changes
|
||||||
|
'realm', -- Realm creation, modification, deletion
|
||||||
|
'scene', -- Scene creation, modification
|
||||||
|
'moderation', -- Moderation actions
|
||||||
|
'prop', -- Prop transfers, creation
|
||||||
|
'admin' -- Administrative actions
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Audit Log
|
||||||
|
-- =============================================================================
|
||||||
|
-- Immutable log of significant events for compliance and debugging.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE audit.events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Event classification
|
||||||
|
category audit.event_category NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- Specific action (e.g., 'login', 'create_realm', 'ban_user')
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
session_id UUID, -- Auth session ID (not FK to allow historical queries)
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
|
||||||
|
-- Target (what was affected)
|
||||||
|
target_type TEXT, -- 'user', 'realm', 'scene', 'prop', etc.
|
||||||
|
target_id UUID,
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
realm_id UUID REFERENCES realm.realms(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Event data
|
||||||
|
details JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Outcome
|
||||||
|
success BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- Immutable timestamp
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE audit.events IS 'Immutable audit log of significant system events';
|
||||||
|
COMMENT ON COLUMN audit.events.details IS 'JSON payload with event-specific data';
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_events_time ON audit.events (created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_events_user ON audit.events (user_id, created_at DESC)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_audit_events_category ON audit.events (category, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_events_target ON audit.events (target_type, target_id, created_at DESC)
|
||||||
|
WHERE target_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_audit_events_realm ON audit.events (realm_id, created_at DESC)
|
||||||
|
WHERE realm_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_audit_events_ip ON audit.events (ip_address, created_at DESC)
|
||||||
|
WHERE ip_address IS NOT NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Login History
|
||||||
|
-- =============================================================================
|
||||||
|
-- Dedicated table for login attempts (success and failure).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE audit.login_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- User (may be NULL for failed attempts with unknown username)
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
attempted_username TEXT,
|
||||||
|
|
||||||
|
-- Attempt details
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
failure_reason TEXT,
|
||||||
|
auth_provider auth.auth_provider,
|
||||||
|
|
||||||
|
-- Client info
|
||||||
|
ip_address INET NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE audit.login_history IS 'History of login attempts for security monitoring';
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_login_history_user ON audit.login_history (user_id, created_at DESC)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_audit_login_history_ip ON audit.login_history (ip_address, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_login_history_failed ON audit.login_history (ip_address, created_at DESC)
|
||||||
|
WHERE success = false;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Data Export Requests (GDPR compliance)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TYPE audit.export_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'processing',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
'expired'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit.data_exports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Request details
|
||||||
|
status audit.export_status NOT NULL DEFAULT 'pending',
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- Processing
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- Result
|
||||||
|
download_path public.asset_path,
|
||||||
|
download_expires_at TIMESTAMPTZ,
|
||||||
|
downloaded_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE audit.data_exports IS 'User data export requests (GDPR compliance)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_data_exports_user ON audit.data_exports (user_id);
|
||||||
|
CREATE INDEX idx_audit_data_exports_status ON audit.data_exports (status)
|
||||||
|
WHERE status IN ('pending', 'processing');
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Account Deletion Requests (GDPR compliance)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TYPE audit.deletion_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'processing',
|
||||||
|
'completed',
|
||||||
|
'cancelled'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE audit.deletion_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Request details
|
||||||
|
status audit.deletion_status NOT NULL DEFAULT 'pending',
|
||||||
|
reason TEXT,
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- Grace period (user can cancel during this time)
|
||||||
|
scheduled_for TIMESTAMPTZ NOT NULL,
|
||||||
|
cancelled_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Processing
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Confirmation
|
||||||
|
confirmed_at TIMESTAMPTZ,
|
||||||
|
confirmation_token_hash TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE audit.deletion_requests IS 'Account deletion requests with grace period';
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_deletion_requests_user ON audit.deletion_requests (user_id);
|
||||||
|
CREATE INDEX idx_audit_deletion_requests_pending ON audit.deletion_requests (scheduled_for)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
123
db/schema/triggers/001_updated_at.sql
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
-- Chattyness Updated At Triggers
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Apply updated_at triggers to all tables with that column
|
||||||
|
-- Load via: psql -f schema/triggers/001_updated_at.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server Schema Triggers
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_server_config_updated_at
|
||||||
|
BEFORE UPDATE ON server.config
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_server_staff_updated_at
|
||||||
|
BEFORE UPDATE ON server.staff
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_server_props_updated_at
|
||||||
|
BEFORE UPDATE ON server.props
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_server_audio_updated_at
|
||||||
|
BEFORE UPDATE ON server.audio
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_server_bans_updated_at
|
||||||
|
BEFORE UPDATE ON server.bans
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_server_mutes_updated_at
|
||||||
|
BEFORE UPDATE ON server.mutes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_server_content_filters_updated_at
|
||||||
|
BEFORE UPDATE ON server.content_filters
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Auth Schema Triggers
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_auth_users_updated_at
|
||||||
|
BEFORE UPDATE ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_auth_inventory_updated_at
|
||||||
|
BEFORE UPDATE ON auth.inventory
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_auth_avatars_updated_at
|
||||||
|
BEFORE UPDATE ON auth.avatars
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_auth_active_avatars_updated_at
|
||||||
|
BEFORE UPDATE ON auth.active_avatars
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm Schema Triggers
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_realms_updated_at
|
||||||
|
BEFORE UPDATE ON realm.realms
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_scenes_updated_at
|
||||||
|
BEFORE UPDATE ON realm.scenes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_memberships_updated_at
|
||||||
|
BEFORE UPDATE ON realm.memberships
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_realm_scripts_updated_at
|
||||||
|
BEFORE UPDATE ON realm.realm_scripts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_props_updated_at
|
||||||
|
BEFORE UPDATE ON realm.props
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_bans_updated_at
|
||||||
|
BEFORE UPDATE ON realm.bans
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_mutes_updated_at
|
||||||
|
BEFORE UPDATE ON realm.mutes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_content_filters_updated_at
|
||||||
|
BEFORE UPDATE ON realm.content_filters
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_realm_reports_updated_at
|
||||||
|
BEFORE UPDATE ON realm.reports
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Scene Schema Triggers
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_scene_instances_updated_at
|
||||||
|
BEFORE UPDATE ON scene.instances
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_scene_spots_updated_at
|
||||||
|
BEFORE UPDATE ON scene.spots
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_scene_scripts_updated_at
|
||||||
|
BEFORE UPDATE ON scene.scripts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_scene_decorations_updated_at
|
||||||
|
BEFORE UPDATE ON scene.decorations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
26
db/schema/triggers/002_user_init.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- Chattyness User Initialization Trigger
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Trigger to initialize new users with default props and avatar.
|
||||||
|
-- Load via: psql -f schema/triggers/002_user_init.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- User Registration Trigger
|
||||||
|
-- =============================================================================
|
||||||
|
-- Automatically initializes new users with default props and avatar
|
||||||
|
-- when they are inserted into auth.users.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_auth_users_initialize
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION auth.initialize_new_user_trigger();
|
||||||
|
|
||||||
|
COMMENT ON TRIGGER trg_auth_users_initialize ON auth.users IS
|
||||||
|
'Initialize new users with default props and avatar on registration';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
225
db/schema/types/001_enums.sql
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
-- Chattyness Enumeration Types
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- All ENUM types used across the database
|
||||||
|
-- Load via: psql -f schema/types/001_enums.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Server-Level Enums
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Server-wide reputation tiers (earned through time and achievements)
|
||||||
|
CREATE TYPE server.reputation_tier AS ENUM (
|
||||||
|
'guest', -- No account, temporary session
|
||||||
|
'member', -- Registered account
|
||||||
|
'established', -- 1 day + achievements
|
||||||
|
'trusted', -- 1 week + achievements
|
||||||
|
'elder' -- 1 month + achievements
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.reputation_tier IS 'Server-wide reputation levels earned through time and activity';
|
||||||
|
|
||||||
|
-- Server-level roles
|
||||||
|
CREATE TYPE server.server_role AS ENUM (
|
||||||
|
'owner', -- Server owner, full control
|
||||||
|
'admin', -- Server administrator
|
||||||
|
'moderator' -- Cross-realm moderation
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.server_role IS 'Administrative roles at the server level';
|
||||||
|
|
||||||
|
-- Audio category
|
||||||
|
CREATE TYPE server.audio_category AS ENUM (
|
||||||
|
'sound_effect',
|
||||||
|
'ambient',
|
||||||
|
'music',
|
||||||
|
'voice'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reserved name type
|
||||||
|
CREATE TYPE server.reserved_name_type AS ENUM (
|
||||||
|
'username',
|
||||||
|
'realm_name',
|
||||||
|
'both'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Prop origin source (moved from props schema)
|
||||||
|
CREATE TYPE server.prop_origin AS ENUM (
|
||||||
|
'server_library', -- From server global library
|
||||||
|
'realm_library', -- From realm-specific library
|
||||||
|
'user_upload' -- User-uploaded content
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.prop_origin IS 'Source of prop creation';
|
||||||
|
|
||||||
|
-- Prop transferability (moved from props schema)
|
||||||
|
CREATE TYPE server.transferability AS ENUM (
|
||||||
|
'transferable', -- Can be traded/given to others
|
||||||
|
'soulbound' -- Bound to original recipient
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.transferability IS 'Whether prop can change ownership';
|
||||||
|
|
||||||
|
-- Prop portability (moved from props schema)
|
||||||
|
CREATE TYPE server.portability AS ENUM (
|
||||||
|
'portable', -- Can be used across realms
|
||||||
|
'realm_locked' -- Only usable in origin realm
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.portability IS 'Whether prop can be used outside origin realm';
|
||||||
|
|
||||||
|
-- Avatar layer (moved from props schema)
|
||||||
|
CREATE TYPE server.avatar_layer AS ENUM (
|
||||||
|
'skin', -- Background layer (behind user, body/face)
|
||||||
|
'clothes', -- Middle layer (with user, worn items)
|
||||||
|
'accessories' -- Foreground layer (in front of user, held/attached items)
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.avatar_layer IS 'Z-layer for avatar prop positioning: skin (behind), clothes (with), accessories (front)';
|
||||||
|
|
||||||
|
-- Emotion state for avatar overlays (moved from props schema)
|
||||||
|
CREATE TYPE server.emotion_state AS ENUM (
|
||||||
|
'neutral',
|
||||||
|
'happy',
|
||||||
|
'sad',
|
||||||
|
'angry',
|
||||||
|
'surprised',
|
||||||
|
'thinking',
|
||||||
|
'laughing',
|
||||||
|
'crying',
|
||||||
|
'love',
|
||||||
|
'confused',
|
||||||
|
'sleeping',
|
||||||
|
'wink'
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.emotion_state IS 'Emotional expression overlay for avatars';
|
||||||
|
|
||||||
|
-- Moderation action type (moved from moderation schema)
|
||||||
|
CREATE TYPE server.action_type AS ENUM (
|
||||||
|
'warning',
|
||||||
|
'mute',
|
||||||
|
'kick',
|
||||||
|
'ban',
|
||||||
|
'unban',
|
||||||
|
'prop_removal',
|
||||||
|
'message_deletion'
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.action_type IS 'Type of moderation action taken';
|
||||||
|
|
||||||
|
-- Report status (moved from moderation schema)
|
||||||
|
CREATE TYPE server.report_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'investigating',
|
||||||
|
'resolved',
|
||||||
|
'dismissed'
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.report_status IS 'Current state of a user report';
|
||||||
|
|
||||||
|
-- Ban scope (moved from moderation schema)
|
||||||
|
CREATE TYPE server.ban_scope AS ENUM (
|
||||||
|
'server', -- Banned from entire server
|
||||||
|
'realm' -- Banned from specific realm
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.ban_scope IS 'Scope of ban enforcement';
|
||||||
|
|
||||||
|
-- Content filter action (moved from moderation schema)
|
||||||
|
CREATE TYPE server.filter_action AS ENUM (
|
||||||
|
'block', -- Prevent message from sending
|
||||||
|
'flag', -- Allow but flag for review
|
||||||
|
'replace' -- Replace matched content
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE server.filter_action IS 'Action to take when content filter matches';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Authentication Enums
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- User account tags for feature gating and access control
|
||||||
|
CREATE TYPE auth.user_tag AS ENUM (
|
||||||
|
'guest',
|
||||||
|
'unvalidated',
|
||||||
|
'validated_email',
|
||||||
|
'validated_social',
|
||||||
|
'validated_oauth2',
|
||||||
|
'premium'
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE auth.user_tag IS 'User account tags for feature gating and access control';
|
||||||
|
|
||||||
|
-- User account status
|
||||||
|
CREATE TYPE auth.account_status AS ENUM (
|
||||||
|
'active',
|
||||||
|
'suspended',
|
||||||
|
'banned',
|
||||||
|
'deleted'
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE auth.account_status IS 'Current state of user account';
|
||||||
|
|
||||||
|
-- Authentication provider
|
||||||
|
CREATE TYPE auth.auth_provider AS ENUM (
|
||||||
|
'local', -- Username/password
|
||||||
|
'oauth_google',
|
||||||
|
'oauth_discord',
|
||||||
|
'oauth_github'
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE auth.auth_provider IS 'Authentication method used for account';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Realm Enums
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Realm privacy settings
|
||||||
|
CREATE TYPE realm.realm_privacy AS ENUM (
|
||||||
|
'public', -- Anyone can enter
|
||||||
|
'unlisted', -- Not in directory, but accessible via link
|
||||||
|
'private' -- Invite only
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE realm.realm_privacy IS 'Visibility and access level for realms';
|
||||||
|
|
||||||
|
-- Realm-level roles
|
||||||
|
CREATE TYPE realm.realm_role AS ENUM (
|
||||||
|
'owner', -- Full control of realm
|
||||||
|
'moderator', -- Moderation within realm
|
||||||
|
'builder', -- Can edit scenes
|
||||||
|
'member' -- Standard member
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE realm.realm_role IS 'Permission roles within a specific realm';
|
||||||
|
|
||||||
|
-- Scene dimension mode
|
||||||
|
CREATE TYPE realm.dimension_mode AS ENUM (
|
||||||
|
'fixed', -- Fixed dimensions, scrollable
|
||||||
|
'viewport' -- Scales to viewport
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE realm.dimension_mode IS 'How scene dimensions are handled';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Scene Enums
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Instance type (renamed from realm.channel_type)
|
||||||
|
CREATE TYPE scene.instance_type AS ENUM (
|
||||||
|
'public', -- Default public instance
|
||||||
|
'private' -- Invite-only instance
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE scene.instance_type IS 'Instance visibility and access type';
|
||||||
|
|
||||||
|
-- Spot type (interactive regions) - moved from realm schema
|
||||||
|
CREATE TYPE scene.spot_type AS ENUM (
|
||||||
|
'normal', -- Generic interactive region
|
||||||
|
'door', -- Navigates to another scene
|
||||||
|
'trigger' -- Fires on enter/exit, no click needed
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE scene.spot_type IS 'Type of interactive spot behavior';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Chat Enums
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Message type
|
||||||
|
CREATE TYPE chat.message_type AS ENUM (
|
||||||
|
'normal', -- Standard chat message
|
||||||
|
'emote', -- Action/emote (/me)
|
||||||
|
'shout', -- Server-wide broadcast
|
||||||
|
'whisper', -- Private direct message
|
||||||
|
'system' -- System announcement
|
||||||
|
);
|
||||||
|
COMMENT ON TYPE chat.message_type IS 'Category of chat message';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
74
db/schema/types/002_domains.sql
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- Chattyness Domain Types
|
||||||
|
-- PostgreSQL 18 with PostGIS
|
||||||
|
--
|
||||||
|
-- Custom domain types with constraints for data validation
|
||||||
|
-- Uses PostGIS GEOMETRY with SRID 0 for virtual 2D coordinate system
|
||||||
|
-- Load via: psql -f schema/types/002_domains.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Common Domains
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Percentage stored as REAL (0.0 to 1.0)
|
||||||
|
CREATE DOMAIN public.percentage AS REAL
|
||||||
|
CHECK (VALUE >= 0.0 AND VALUE <= 1.0);
|
||||||
|
COMMENT ON DOMAIN public.percentage IS 'Percentage value stored as 0.0-1.0 REAL';
|
||||||
|
|
||||||
|
-- Non-empty text (prevents empty strings where we need content)
|
||||||
|
CREATE DOMAIN public.nonempty_text AS TEXT
|
||||||
|
CHECK (length(trim(VALUE)) > 0);
|
||||||
|
COMMENT ON DOMAIN public.nonempty_text IS 'Text that cannot be empty or whitespace-only';
|
||||||
|
|
||||||
|
-- Username format (lowercase alphanumeric with underscores)
|
||||||
|
CREATE DOMAIN auth.username AS TEXT
|
||||||
|
CHECK (VALUE ~ '^[a-z][a-z0-9_]{2,29}$');
|
||||||
|
COMMENT ON DOMAIN auth.username IS 'Valid username: 3-30 chars, starts with letter, alphanumeric and underscores';
|
||||||
|
|
||||||
|
-- Display name (visible name, more permissive)
|
||||||
|
CREATE DOMAIN public.display_name AS TEXT
|
||||||
|
CHECK (length(trim(VALUE)) BETWEEN 1 AND 50);
|
||||||
|
COMMENT ON DOMAIN public.display_name IS 'Visible display name, 1-50 characters';
|
||||||
|
|
||||||
|
-- Slug for URLs (realm names, scene names)
|
||||||
|
CREATE DOMAIN public.slug AS TEXT
|
||||||
|
CHECK (VALUE ~ '^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$' OR VALUE ~ '^[a-z0-9]{1,2}$');
|
||||||
|
COMMENT ON DOMAIN public.slug IS 'URL-safe slug: 1-50 chars, lowercase alphanumeric and hyphens';
|
||||||
|
|
||||||
|
-- Email address (basic validation)
|
||||||
|
CREATE DOMAIN auth.email AS TEXT
|
||||||
|
CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||||
|
COMMENT ON DOMAIN auth.email IS 'Basic email address format validation';
|
||||||
|
|
||||||
|
-- Color as hex string
|
||||||
|
CREATE DOMAIN public.hex_color AS TEXT
|
||||||
|
CHECK (VALUE ~ '^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$');
|
||||||
|
COMMENT ON DOMAIN public.hex_color IS 'Hex color code (#RRGGBB or #RRGGBBAA)';
|
||||||
|
|
||||||
|
-- URL validation (basic)
|
||||||
|
CREATE DOMAIN public.url AS TEXT
|
||||||
|
CHECK (VALUE ~ '^https?://[^\s]+$');
|
||||||
|
COMMENT ON DOMAIN public.url IS 'HTTP/HTTPS URL';
|
||||||
|
|
||||||
|
-- Asset path (relative path within storage)
|
||||||
|
CREATE DOMAIN public.asset_path AS TEXT
|
||||||
|
CHECK (VALUE ~ '^[a-zA-Z0-9/_.-]+$' AND length(VALUE) <= 500);
|
||||||
|
COMMENT ON DOMAIN public.asset_path IS 'Valid asset storage path';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- PostGIS Spatial Domains (SRID 0 for virtual 2D world)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Point in 2D virtual space (user position, prop position)
|
||||||
|
-- Using SRID 0 for non-geographic Cartesian coordinate system
|
||||||
|
CREATE DOMAIN public.virtual_point AS GEOMETRY(POINT, 0);
|
||||||
|
COMMENT ON DOMAIN public.virtual_point IS 'Point in 2D virtual space (SRID 0 Cartesian coordinates)';
|
||||||
|
|
||||||
|
-- 2D bounding box for scenes (defines playable area)
|
||||||
|
CREATE DOMAIN public.scene_bounds AS GEOMETRY(POLYGON, 0);
|
||||||
|
COMMENT ON DOMAIN public.scene_bounds IS 'Rectangular bounding box defining scene dimensions';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
132
db/seeds/development.sql
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
-- Chattyness Development Seed Data
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Sample data for development and testing.
|
||||||
|
-- Load via: psql -f seeds/development.sql
|
||||||
|
--
|
||||||
|
-- WARNING: This will insert data. Run only on development databases.
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Test Users
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO auth.users (id, username, email, password_hash, display_name, reputation_tier, status)
|
||||||
|
VALUES
|
||||||
|
('11111111-1111-1111-1111-111111111111', 'admin', 'admin@example.com',
|
||||||
|
'$2a$12$dummy.hash.for.development.only', 'Server Admin', 'elder', 'active'),
|
||||||
|
('22222222-2222-2222-2222-222222222222', 'alice', 'alice@example.com',
|
||||||
|
'$2a$12$dummy.hash.for.development.only', 'Alice', 'trusted', 'active'),
|
||||||
|
('33333333-3333-3333-3333-333333333333', 'bob', 'bob@example.com',
|
||||||
|
'$2a$12$dummy.hash.for.development.only', 'Bob', 'established', 'active'),
|
||||||
|
('44444444-4444-4444-4444-444444444444', 'charlie', 'charlie@example.com',
|
||||||
|
'$2a$12$dummy.hash.for.development.only', 'Charlie', 'member', 'active')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Make admin a server admin
|
||||||
|
INSERT INTO server.staff (user_id, role, appointed_by)
|
||||||
|
VALUES ('11111111-1111-1111-1111-111111111111', 'owner', '11111111-1111-1111-1111-111111111111')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Sample Props in Server Library
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO server.props (id, name, slug, description, asset_path, default_layer, default_position, tags)
|
||||||
|
VALUES
|
||||||
|
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Red Hat', 'red-hat',
|
||||||
|
'A stylish red hat', 'props/hats/red-hat.png', 'accessories', 1, ARRAY['hat', 'accessory', 'red']),
|
||||||
|
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Blue Shirt', 'blue-shirt',
|
||||||
|
'A casual blue shirt', 'props/clothing/blue-shirt.png', 'clothes', 4, ARRAY['shirt', 'clothing', 'blue']),
|
||||||
|
('cccccccc-cccc-cccc-cccc-cccccccccccc', 'Gold Coin', 'gold-coin',
|
||||||
|
'A shiny gold coin', 'props/items/gold-coin.png', NULL, NULL, ARRAY['currency', 'collectible']),
|
||||||
|
('dddddddd-dddd-dddd-dddd-dddddddddddd', 'Trophy', 'trophy',
|
||||||
|
'Achievement trophy', 'props/items/trophy.png', NULL, NULL, ARRAY['achievement', 'collectible'])
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Sample Realm
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO realm.realms (id, name, slug, description, owner_id, privacy)
|
||||||
|
VALUES
|
||||||
|
('55555555-5555-5555-5555-555555555555', 'Welcome Plaza', 'welcome-plaza',
|
||||||
|
'The main gathering place for new users', '11111111-1111-1111-1111-111111111111', 'public')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Sample Scenes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO realm.scenes (id, realm_id, name, slug, description, bounds, is_entry_point, sort_order)
|
||||||
|
VALUES
|
||||||
|
('66666666-6666-6666-6666-666666666666', '55555555-5555-5555-5555-555555555555',
|
||||||
|
'Main Hall', 'main-hall', 'The central gathering space',
|
||||||
|
ST_MakeEnvelope(0, 0, 1024, 768, 0), true, 0),
|
||||||
|
('77777777-7777-7777-7777-777777777777', '55555555-5555-5555-5555-555555555555',
|
||||||
|
'Garden', 'garden', 'A peaceful outdoor garden',
|
||||||
|
ST_MakeEnvelope(0, 0, 1280, 720, 0), false, 1)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Set default scene for realm
|
||||||
|
UPDATE realm.realms
|
||||||
|
SET default_scene_id = '66666666-6666-6666-6666-666666666666'
|
||||||
|
WHERE id = '55555555-5555-5555-5555-555555555555';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Sample Channels (Public instances of scenes)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO realm.channels (id, scene_id, channel_type, max_users)
|
||||||
|
VALUES
|
||||||
|
('88888888-8888-8888-8888-888888888888', '66666666-6666-6666-6666-666666666666', 'public', 50),
|
||||||
|
('99999999-9999-9999-9999-999999999999', '77777777-7777-7777-7777-777777777777', 'public', 50)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Sample Memberships
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
VALUES
|
||||||
|
('55555555-5555-5555-5555-555555555555', '11111111-1111-1111-1111-111111111111', 'owner'),
|
||||||
|
('55555555-5555-5555-5555-555555555555', '22222222-2222-2222-2222-222222222222', 'moderator'),
|
||||||
|
('55555555-5555-5555-5555-555555555555', '33333333-3333-3333-3333-333333333333', 'member'),
|
||||||
|
('55555555-5555-5555-5555-555555555555', '44444444-4444-4444-4444-444444444444', 'member')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Sample Spots
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO realm.spots (scene_id, name, slug, region, spot_type)
|
||||||
|
VALUES
|
||||||
|
('66666666-6666-6666-6666-666666666666', 'Info Desk', 'info-desk',
|
||||||
|
ST_SetSRID(ST_MakePolygon(ST_MakeLine(ARRAY[
|
||||||
|
ST_MakePoint(100, 100), ST_MakePoint(200, 100),
|
||||||
|
ST_MakePoint(200, 200), ST_MakePoint(100, 200),
|
||||||
|
ST_MakePoint(100, 100)
|
||||||
|
])), 0),
|
||||||
|
'normal'),
|
||||||
|
('66666666-6666-6666-6666-666666666666', 'Garden Portal', 'garden-portal',
|
||||||
|
ST_SetSRID(ST_Buffer(ST_MakePoint(800, 400), 50), 0),
|
||||||
|
'door')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Sample Friendships
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT INTO auth.friendships (friend_a, friend_b, initiated_by, is_accepted, accepted_at)
|
||||||
|
VALUES
|
||||||
|
-- Alice and Bob are friends (Alice < Bob alphabetically in UUIDs)
|
||||||
|
('22222222-2222-2222-2222-222222222222', '33333333-3333-3333-3333-333333333333',
|
||||||
|
'22222222-2222-2222-2222-222222222222', true, now())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
\echo 'Development seed data loaded successfully!'
|
||||||
BIN
stock/.playwright-mcp/cccp-flag.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
stock/.playwright-mcp/cccp-local.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
stock/.playwright-mcp/cccp-v3.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
20
stock/avatar/angry.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - narrowed -->
|
||||||
|
<ellipse cx="16" cy="20" rx="3" ry="2.5" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="15" cy="19" rx="1" ry="1" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Left eyebrow - angled inward/down -->
|
||||||
|
<path d="M 10 14 L 20 17" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Right eye - narrowed -->
|
||||||
|
<ellipse cx="32" cy="20" rx="3" ry="2.5" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="31" cy="19" rx="1" ry="1" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Right eyebrow - angled inward/down -->
|
||||||
|
<path d="M 28 17 L 38 14" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Angry mouth - tight frown -->
|
||||||
|
<path d="M 16 32 Q 24 28 32 32" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 982 B |
23
stock/avatar/confused.svg
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - normal -->
|
||||||
|
<ellipse cx="16" cy="19" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="15" cy="17" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Left eyebrow - flat/slightly down -->
|
||||||
|
<path d="M 10 14 Q 16 14 22 15" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Right eye - slightly squinted -->
|
||||||
|
<ellipse cx="32" cy="20" rx="3" ry="3" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="31" cy="18" rx="1" ry="1.2" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Right eyebrow - raised/quirked -->
|
||||||
|
<path d="M 26 12 Q 32 9 38 13" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Confused mouth - wavy/uncertain -->
|
||||||
|
<path d="M 14 32 Q 18 30 22 33 Q 26 30 30 33 Q 34 30 34 32" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Question mark thought -->
|
||||||
|
<text x="40" y="12" font-family="Arial, sans-serif" font-size="10" fill="#000000" opacity="0.6">?</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
30
stock/avatar/crying.svg
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - squinting/crying -->
|
||||||
|
<ellipse cx="16" cy="20" rx="2.5" ry="2" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="15" cy="19" rx="0.8" ry="0.6" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Left eyebrow - angled down sharply -->
|
||||||
|
<path d="M 10 14 Q 16 12 21 15" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Right eye - squinting/crying -->
|
||||||
|
<ellipse cx="32" cy="20" rx="2.5" ry="2" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="31" cy="19" rx="0.8" ry="0.6" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Right eyebrow - angled down sharply -->
|
||||||
|
<path d="M 27 15 Q 32 12 38 14" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Crying frown - wavy/trembling -->
|
||||||
|
<path d="M 14 34 Q 19 28 24 31 Q 29 28 34 34" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Left tear stream -->
|
||||||
|
<ellipse cx="18" cy="26" rx="2" ry="2.5" fill="#6BC5E8" opacity="0.8"/>
|
||||||
|
<ellipse cx="17" cy="31" rx="1.5" ry="2" fill="#6BC5E8" opacity="0.7"/>
|
||||||
|
<ellipse cx="16" cy="36" rx="1" ry="1.5" fill="#6BC5E8" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Right tear stream -->
|
||||||
|
<ellipse cx="30" cy="26" rx="2" ry="2.5" fill="#6BC5E8" opacity="0.8"/>
|
||||||
|
<ellipse cx="31" cy="31" rx="1.5" ry="2" fill="#6BC5E8" opacity="0.7"/>
|
||||||
|
<ellipse cx="32" cy="36" rx="1" ry="1.5" fill="#6BC5E8" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
45
stock/avatar/face.svg
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--face-primary: #FFCC00;
|
||||||
|
--face-highlight: #FFE566;
|
||||||
|
--face-shadow: #CC9900;
|
||||||
|
}
|
||||||
|
.face-primary { stop-color: var(--face-primary); }
|
||||||
|
.face-highlight { stop-color: var(--face-highlight); }
|
||||||
|
.face-shadow { stop-color: var(--face-shadow); }
|
||||||
|
.face-stroke { stroke: var(--face-shadow); }
|
||||||
|
.bevel-fill { fill: var(--face-primary); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<!-- Radial gradient for 3D sphere effect -->
|
||||||
|
<radialGradient id="faceGradient" cx="35%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="50%" class="face-highlight"/>
|
||||||
|
<stop offset="100%" class="face-primary"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Darker gradient for bottom edge (bevel effect) -->
|
||||||
|
<radialGradient id="shadowGradient" cx="50%" cy="0%" r="100%">
|
||||||
|
<stop offset="60%" class="face-primary"/>
|
||||||
|
<stop offset="100%" class="face-shadow"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Drop shadow filter -->
|
||||||
|
<filter id="dropShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Main face with gradient and shadow -->
|
||||||
|
<circle cx="24" cy="24" r="20" fill="url(#faceGradient)" class="face-stroke" stroke-width="1.5" filter="url(#dropShadow)"/>
|
||||||
|
|
||||||
|
<!-- Subtle bottom bevel overlay -->
|
||||||
|
<ellipse cx="24" cy="32" rx="18" ry="12" fill="url(#shadowGradient)" opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- Specular highlight (top-left light reflection) -->
|
||||||
|
<ellipse cx="16" cy="14" rx="6" ry="4" fill="#FFFFFF" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
18
stock/avatar/laughing.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - closed/squinting (happy arc) -->
|
||||||
|
<path d="M 12 18 Q 16 14 20 18" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Right eye - closed/squinting (happy arc) -->
|
||||||
|
<path d="M 28 18 Q 32 14 36 18" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Big open laughing mouth -->
|
||||||
|
<path d="M 12 28 Q 24 44 36 28" fill="#000000" stroke="#000000" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- Tongue/inner mouth -->
|
||||||
|
<ellipse cx="24" cy="34" rx="6" ry="4" fill="#CC4444"/>
|
||||||
|
|
||||||
|
<!-- Laugh lines / tears of joy -->
|
||||||
|
<path d="M 8 20 Q 10 22 8 24" fill="none" stroke="#6BC5E8" stroke-width="1" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M 40 20 Q 38 22 40 24" fill="none" stroke="#6BC5E8" stroke-width="1" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 978 B |
22
stock/avatar/love.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left heart eye -->
|
||||||
|
<path d="M 16 16 C 13 13 9 16 9 19 C 9 23 16 27 16 27 C 16 27 23 23 23 19 C 23 16 19 13 16 16" fill="#FF1493"/>
|
||||||
|
<!-- Left heart highlight -->
|
||||||
|
<ellipse cx="12" cy="17" rx="1.5" ry="1" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Right heart eye -->
|
||||||
|
<path d="M 32 16 C 29 13 25 16 25 19 C 25 23 32 27 32 27 C 32 27 39 23 39 19 C 39 16 35 13 32 16" fill="#FF1493"/>
|
||||||
|
<!-- Right heart highlight -->
|
||||||
|
<ellipse cx="28" cy="17" rx="1.5" ry="1" fill="#FFFFFF" opacity="0.5"/>
|
||||||
|
|
||||||
|
<!-- Happy/dreamy smile -->
|
||||||
|
<path d="M 12 30 Q 24 42 36 30" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<!-- Inner smile shadow for depth -->
|
||||||
|
<path d="M 14 31 Q 24 40 34 31" fill="none" stroke="#996600" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Blush marks -->
|
||||||
|
<ellipse cx="10" cy="28" rx="3" ry="2" fill="#FF6B6B" opacity="0.4"/>
|
||||||
|
<ellipse cx="38" cy="28" rx="3" ry="2" fill="#FF6B6B" opacity="0.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
16
stock/avatar/neutral.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye -->
|
||||||
|
<ellipse cx="16" cy="19" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="15" cy="17" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Right eye -->
|
||||||
|
<ellipse cx="32" cy="19" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="31" cy="17" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Neutral straight mouth -->
|
||||||
|
<path d="M 14 30 L 34 30" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 654 B |
23
stock/avatar/sad.svg
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - droopy -->
|
||||||
|
<ellipse cx="16" cy="20" rx="3" ry="3.5" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="15" cy="18.5" rx="1" ry="1.2" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Left eyebrow - angled down -->
|
||||||
|
<path d="M 11 14 Q 16 13 20 15" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Right eye - droopy -->
|
||||||
|
<ellipse cx="32" cy="20" rx="3" ry="3.5" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="31" cy="18.5" rx="1" ry="1.2" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Right eyebrow - angled down -->
|
||||||
|
<path d="M 28 15 Q 32 13 37 14" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Sad frown - inverted curve -->
|
||||||
|
<path d="M 14 33 Q 24 26 34 33" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Optional tear drop -->
|
||||||
|
<ellipse cx="20" cy="26" rx="1.5" ry="2" fill="#6BC5E8" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
21
stock/avatar/sleeping.svg
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - closed (sleeping arc, curves down) -->
|
||||||
|
<path d="M 12 20 Q 16 23 20 20" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Right eye - closed (sleeping arc, curves down) -->
|
||||||
|
<path d="M 28 20 Q 32 23 36 20" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Sleeping mouth - small open snore -->
|
||||||
|
<ellipse cx="24" cy="32" rx="3" ry="2" fill="#000000"/>
|
||||||
|
|
||||||
|
<!-- Z's for sleeping -->
|
||||||
|
<text x="38" y="12" font-family="sans-serif" font-size="8" font-weight="bold" fill="#000000">Z</text>
|
||||||
|
<text x="42" y="8" font-family="sans-serif" font-size="6" font-weight="bold" fill="#000000" opacity="0.7">z</text>
|
||||||
|
<text x="45" y="5" font-family="sans-serif" font-size="4" font-weight="bold" fill="#000000" opacity="0.5">z</text>
|
||||||
|
|
||||||
|
<!-- Blush/rosy cheeks -->
|
||||||
|
<ellipse cx="10" cy="26" rx="3" ry="2" fill="#FF9999" opacity="0.3"/>
|
||||||
|
<ellipse cx="38" cy="26" rx="3" ry="2" fill="#FF9999" opacity="0.3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
18
stock/avatar/smile.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye -->
|
||||||
|
<ellipse cx="16" cy="19" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="15" cy="17" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Right eye -->
|
||||||
|
<ellipse cx="32" cy="19" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="31" cy="17" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Smile with slight depth -->
|
||||||
|
<path d="M 12 28 Q 24 40 36 28" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<!-- Inner smile shadow for depth -->
|
||||||
|
<path d="M 14 29 Q 24 38 34 29" fill="none" stroke="#996600" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 824 B |
22
stock/avatar/surprised.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - wide open -->
|
||||||
|
<ellipse cx="16" cy="18" rx="4" ry="5" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="14.5" cy="16" rx="1.5" ry="2" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Left eyebrow - raised -->
|
||||||
|
<path d="M 10 11 Q 16 9 22 11" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Right eye - wide open -->
|
||||||
|
<ellipse cx="32" cy="18" rx="4" ry="5" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="30.5" cy="16" rx="1.5" ry="2" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
<!-- Right eyebrow - raised -->
|
||||||
|
<path d="M 26 11 Q 32 9 38 11" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Surprised open mouth - oval -->
|
||||||
|
<ellipse cx="24" cy="32" rx="5" ry="6" fill="#000000"/>
|
||||||
|
<!-- Inner mouth -->
|
||||||
|
<ellipse cx="24" cy="33" rx="3" ry="4" fill="#8B0000"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1,016 B |
19
stock/avatar/thinking.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - looking up/side -->
|
||||||
|
<ellipse cx="15" cy="18" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="13.5" cy="16" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Right eye - looking up/side -->
|
||||||
|
<ellipse cx="31" cy="18" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Right eye highlight -->
|
||||||
|
<ellipse cx="29.5" cy="16" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- One raised eyebrow -->
|
||||||
|
<path d="M 26 12 Q 32 10 38 13" fill="none" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Thinking mouth - slight sideways purse -->
|
||||||
|
<path d="M 18 31 Q 22 30 26 32 Q 30 34 32 32" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 872 B |
218
stock/avatar/upload-stockavatars.sh
Executable file
|
|
@ -0,0 +1,218 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Upload all stock avatar props to the server.
|
||||||
|
#
|
||||||
|
# Usage: ./stockavatar/upload-stockavatars.sh [--force|-f] [HOST]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --force, -f Update existing props instead of failing with 409 Conflict
|
||||||
|
#
|
||||||
|
# HOST defaults to http://localhost:3001 (owner admin port)
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. Run the dev server: ./run-dev.sh -f
|
||||||
|
# 2. Wait for it to finish building: ./run-dev.sh -s
|
||||||
|
#
|
||||||
|
# The owner admin server (port 3001) uses the chattyness_owner DB role
|
||||||
|
# which bypasses RLS, so no authentication is required.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
FORCE=""
|
||||||
|
HOST="http://localhost:3001"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force|-f)
|
||||||
|
FORCE="?force=true"
|
||||||
|
;;
|
||||||
|
http://*)
|
||||||
|
HOST="$arg"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# Script directory is the stockavatar directory
|
||||||
|
STOCKAVATAR_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "Uploading stock avatars to $HOST/api/admin/props"
|
||||||
|
echo "Source directory: $STOCKAVATAR_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
echo "Checking server health..."
|
||||||
|
health_response=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/api/admin/health" 2>/dev/null || echo "000")
|
||||||
|
if [ "$health_response" != "200" ]; then
|
||||||
|
echo "ERROR: Server is not responding at $HOST (HTTP $health_response)"
|
||||||
|
echo ""
|
||||||
|
echo "Make sure the server is running:"
|
||||||
|
echo " ./run-dev.sh -f"
|
||||||
|
echo " ./run-dev.sh -s # Check status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Server is healthy!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to capitalize first letter
|
||||||
|
capitalize() {
|
||||||
|
echo "$1" | sed 's/.*/\u&/'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to determine tags based on filename
|
||||||
|
# Tags complement default_layer/default_emotion - avoid redundant info
|
||||||
|
get_tags() {
|
||||||
|
local filename="$1"
|
||||||
|
case "$filename" in
|
||||||
|
face.svg)
|
||||||
|
# Content layer prop - "skin" is already in default_layer
|
||||||
|
echo '["base", "face"]'
|
||||||
|
;;
|
||||||
|
smile.svg | happy.svg | neutral.svg | sad.svg | angry.svg | surprised.svg | thinking.svg | laughing.svg | crying.svg | love.svg | confused.svg)
|
||||||
|
# Emotion props - emotion is already in default_emotion
|
||||||
|
echo '["face"]'
|
||||||
|
;;
|
||||||
|
sleeping.svg)
|
||||||
|
# Emotion prop for sleeping
|
||||||
|
echo '["face"]'
|
||||||
|
;;
|
||||||
|
wink.svg)
|
||||||
|
# Emotion prop for wink
|
||||||
|
echo '["face"]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo '["prop"]'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get positioning fields based on filename
|
||||||
|
# Returns: "layer:<value>" for content layer props, "emotion:<value>" for emotion props, "none" for generic props
|
||||||
|
get_positioning() {
|
||||||
|
local filename="$1"
|
||||||
|
case "$filename" in
|
||||||
|
face.svg)
|
||||||
|
# Base face is a content layer prop (skin layer)
|
||||||
|
echo "layer:skin"
|
||||||
|
;;
|
||||||
|
neutral.svg)
|
||||||
|
echo "emotion:neutral"
|
||||||
|
;;
|
||||||
|
smile.svg)
|
||||||
|
echo "emotion:happy"
|
||||||
|
;;
|
||||||
|
sad.svg)
|
||||||
|
echo "emotion:sad"
|
||||||
|
;;
|
||||||
|
angry.svg)
|
||||||
|
echo "emotion:angry"
|
||||||
|
;;
|
||||||
|
surprised.svg)
|
||||||
|
echo "emotion:surprised"
|
||||||
|
;;
|
||||||
|
thinking.svg)
|
||||||
|
echo "emotion:thinking"
|
||||||
|
;;
|
||||||
|
laughing.svg)
|
||||||
|
echo "emotion:laughing"
|
||||||
|
;;
|
||||||
|
crying.svg)
|
||||||
|
echo "emotion:crying"
|
||||||
|
;;
|
||||||
|
love.svg)
|
||||||
|
echo "emotion:love"
|
||||||
|
;;
|
||||||
|
confused.svg)
|
||||||
|
echo "emotion:confused"
|
||||||
|
;;
|
||||||
|
sleeping.svg)
|
||||||
|
echo "emotion:sleeping"
|
||||||
|
;;
|
||||||
|
wink.svg)
|
||||||
|
echo "emotion:wink"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "none"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Track success/failure counts
|
||||||
|
success_count=0
|
||||||
|
fail_count=0
|
||||||
|
|
||||||
|
# Upload each SVG file
|
||||||
|
for file in "$STOCKAVATAR_DIR"/*.svg; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename=$(basename "$file")
|
||||||
|
name_without_ext="${filename%.svg}"
|
||||||
|
display_name=$(capitalize "$name_without_ext")
|
||||||
|
tags=$(get_tags "$filename")
|
||||||
|
positioning=$(get_positioning "$filename")
|
||||||
|
|
||||||
|
echo "Uploading: $filename -> $display_name (positioning: $positioning)"
|
||||||
|
|
||||||
|
# Build positioning fields based on type
|
||||||
|
positioning_type="${positioning%%:*}"
|
||||||
|
positioning_value="${positioning#*:}"
|
||||||
|
|
||||||
|
case "$positioning_type" in
|
||||||
|
layer)
|
||||||
|
# Content layer prop
|
||||||
|
positioning_json="\"default_layer\": \"$positioning_value\", \"default_position\": 4"
|
||||||
|
;;
|
||||||
|
emotion)
|
||||||
|
# Emotion layer prop
|
||||||
|
positioning_json="\"default_emotion\": \"$positioning_value\", \"default_position\": 4"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Generic prop (no default positioning)
|
||||||
|
positioning_json=""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Create metadata JSON
|
||||||
|
if [ -n "$positioning_json" ]; then
|
||||||
|
metadata=$(
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"name": "$display_name",
|
||||||
|
"tags": $tags,
|
||||||
|
$positioning_json
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
else
|
||||||
|
metadata=$(
|
||||||
|
cat <<EOF
|
||||||
|
{
|
||||||
|
"name": "$display_name",
|
||||||
|
"tags": $tags
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload via curl
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST "$HOST/api/admin/props$FORCE" \
|
||||||
|
-F "metadata=$metadata" \
|
||||||
|
-F "file=@$file")
|
||||||
|
|
||||||
|
# Extract HTTP status code (last line)
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||||
|
echo " ✓ Success: $body"
|
||||||
|
((++success_count))
|
||||||
|
else
|
||||||
|
echo " ✗ Failed (HTTP $http_code): $body"
|
||||||
|
((++fail_count))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Upload complete: $success_count succeeded, $fail_count failed"
|
||||||
19
stock/avatar/wink.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<!-- Left eye - open -->
|
||||||
|
<ellipse cx="16" cy="19" rx="3" ry="4" fill="#000000"/>
|
||||||
|
<!-- Left eye highlight -->
|
||||||
|
<ellipse cx="15" cy="17" rx="1" ry="1.5" fill="#FFFFFF" opacity="0.8"/>
|
||||||
|
|
||||||
|
<!-- Right eye - winking (closed arc) -->
|
||||||
|
<path d="M 28 19 Q 32 16 36 19" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Cheeky smile -->
|
||||||
|
<path d="M 12 28 Q 24 40 36 28" fill="none" stroke="#000000" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<!-- Inner smile shadow -->
|
||||||
|
<path d="M 14 29 Q 24 38 34 29" fill="none" stroke="#996600" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Dimple/blush on wink side -->
|
||||||
|
<ellipse cx="38" cy="24" rx="3" ry="2" fill="#FF9999" opacity="0.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 887 B |
31
stock/flags/chinese.svg
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 120 100" width="120" height="100">
|
||||||
|
<defs>
|
||||||
|
<filter id="flagshadow-cn" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1" flood-color="#000000" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
<!-- Star path from official Wikipedia SVG -->
|
||||||
|
<path id="star" d="m0-30 17.634 54.27-46.166-33.54h57.064l-46.166 33.54Z" fill="#FF0"/>
|
||||||
|
<clipPath id="waveclip-cn">
|
||||||
|
<path d="M 15 15 Q 40 10 65 15 Q 90 20 110 15 L 110 75 Q 90 80 65 75 Q 40 70 15 75 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Flag with wave effect -->
|
||||||
|
<g filter="url(#flagshadow-cn)" clip-path="url(#waveclip-cn)">
|
||||||
|
<!-- Original is 900x600, we scale to fit ~95x63 area -->
|
||||||
|
<g transform="translate(15, 15) scale(0.105555)">
|
||||||
|
<path fill="#EE1C25" d="M0 0h900v600H0"/>
|
||||||
|
<g transform="matrix(3 0 0 3 150 150)">
|
||||||
|
<use xlink:href="#star"/>
|
||||||
|
</g>
|
||||||
|
<use xlink:href="#star" transform="rotate(23.036 2.784 766.082)"/>
|
||||||
|
<use xlink:href="#star" transform="rotate(45.87 38.201 485.396)"/>
|
||||||
|
<use xlink:href="#star" transform="rotate(69.945 29.892 362.328)"/>
|
||||||
|
<use xlink:href="#star" transform="rotate(20.66 -590.66 957.955)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Flag pole -->
|
||||||
|
<rect x="10" y="10" width="5" height="85" rx="1" fill="#8B4513"/>
|
||||||
|
<circle cx="12.5" cy="10" r="4" fill="#FFD700"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
732
stock/index.html
Normal file
|
|
@ -0,0 +1,732 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Stock Assets Viewer</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab navigation */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #2a2a4e;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
color: #eee;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background: #3a3a5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:focus {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #3a3a5e;
|
||||||
|
border-color: #4ECDC4;
|
||||||
|
border-bottom-color: #3a3a5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar section */
|
||||||
|
.avatar-container {
|
||||||
|
position: relative;
|
||||||
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-layer svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn:focus {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emotions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emotion-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #2a2a4e;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #eee;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emotion-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
border-color: #666;
|
||||||
|
background: #3a3a5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emotion-btn:focus {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emotion-btn.active {
|
||||||
|
border-color: #4ECDC4;
|
||||||
|
background: #3a3a5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emotion-btn .hotkey {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #888;
|
||||||
|
background: #1a1a2e;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Props section */
|
||||||
|
.props-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-category {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-category h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4ECDC4;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-items {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #2a2a4e;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: transform 0.15s, border-color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-card:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
border-color: #4ECDC4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-card:focus {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-card.selected {
|
||||||
|
border-color: #4ECDC4;
|
||||||
|
background: #3a3a5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-preview {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-preview svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected prop display */
|
||||||
|
.selected-prop-container {
|
||||||
|
width: 192px;
|
||||||
|
height: 192px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #2a2a4e;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-prop-container svg {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Stock Assets Viewer</h1>
|
||||||
|
|
||||||
|
<nav class="tabs" role="tablist">
|
||||||
|
<button class="tab-btn active" data-tab="avatars" role="tab" aria-selected="true">Avatars</button>
|
||||||
|
<button class="tab-btn" data-tab="props" role="tab" aria-selected="false">Props</button>
|
||||||
|
<button class="tab-btn" data-tab="flags" role="tab" aria-selected="false">Flags</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Avatars Tab -->
|
||||||
|
<section id="avatars-tab" class="tab-content active" role="tabpanel" aria-labelledby="avatars-tab-btn">
|
||||||
|
<div class="avatar-container">
|
||||||
|
<div id="face-layer" class="avatar-layer"></div>
|
||||||
|
<div id="expression-layer" class="avatar-layer"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-section">
|
||||||
|
<h2>Face Color</h2>
|
||||||
|
<div class="color-picker-row">
|
||||||
|
<input type="color" id="colorPicker" value="#FFCC00" aria-label="Face color picker">
|
||||||
|
</div>
|
||||||
|
<div class="presets" role="group" aria-label="Color presets">
|
||||||
|
<button class="preset-btn" style="background: #FFCC00;" data-color="#FFCC00" title="Yellow" aria-label="Yellow"></button>
|
||||||
|
<button class="preset-btn" style="background: #FF6B6B;" data-color="#FF6B6B" title="Red" aria-label="Red"></button>
|
||||||
|
<button class="preset-btn" style="background: #4ECDC4;" data-color="#4ECDC4" title="Teal" aria-label="Teal"></button>
|
||||||
|
<button class="preset-btn" style="background: #95E1D3;" data-color="#95E1D3" title="Mint" aria-label="Mint"></button>
|
||||||
|
<button class="preset-btn" style="background: #DDA0DD;" data-color="#DDA0DD" title="Plum" aria-label="Plum"></button>
|
||||||
|
<button class="preset-btn" style="background: #87CEEB;" data-color="#87CEEB" title="Sky Blue" aria-label="Sky Blue"></button>
|
||||||
|
<button class="preset-btn" style="background: #FFB347;" data-color="#FFB347" title="Orange" aria-label="Orange"></button>
|
||||||
|
<button class="preset-btn" style="background: #98D8C8;" data-color="#98D8C8" title="Seafoam" aria-label="Seafoam"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-section">
|
||||||
|
<h2>Emotion</h2>
|
||||||
|
<div class="emotions" role="group" aria-label="Emotion selection">
|
||||||
|
<button class="emotion-btn" data-emotion="neutral" data-key="0">
|
||||||
|
<span>Neutral</span>
|
||||||
|
<span class="hotkey">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn active" data-emotion="smile" data-key="1">
|
||||||
|
<span>Happy</span>
|
||||||
|
<span class="hotkey">1</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="sad" data-key="2">
|
||||||
|
<span>Sad</span>
|
||||||
|
<span class="hotkey">2</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="angry" data-key="3">
|
||||||
|
<span>Angry</span>
|
||||||
|
<span class="hotkey">3</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="surprised" data-key="4">
|
||||||
|
<span>Surprised</span>
|
||||||
|
<span class="hotkey">4</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="thinking" data-key="5">
|
||||||
|
<span>Thinking</span>
|
||||||
|
<span class="hotkey">5</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="laughing" data-key="6">
|
||||||
|
<span>Laughing</span>
|
||||||
|
<span class="hotkey">6</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="wink" data-key="7">
|
||||||
|
<span>Wink</span>
|
||||||
|
<span class="hotkey">7</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="sleeping" data-key="8">
|
||||||
|
<span>Sleeping</span>
|
||||||
|
<span class="hotkey">8</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="confused" data-key="9">
|
||||||
|
<span>Confused</span>
|
||||||
|
<span class="hotkey">9</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="crying" data-key="c">
|
||||||
|
<span>Crying</span>
|
||||||
|
<span class="hotkey">C</span>
|
||||||
|
</button>
|
||||||
|
<button class="emotion-btn" data-emotion="love" data-key="l">
|
||||||
|
<span>Love</span>
|
||||||
|
<span class="hotkey">L</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Press 0-9, C, L to switch emotions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Props Tab -->
|
||||||
|
<section id="props-tab" class="tab-content" role="tabpanel" aria-labelledby="props-tab-btn">
|
||||||
|
<div class="selected-prop-container" id="selected-prop" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div class="props-grid">
|
||||||
|
<div class="prop-category">
|
||||||
|
<h3>Hookahs</h3>
|
||||||
|
<div class="prop-items" id="hookah-props" role="group" aria-label="Hookah props"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-category">
|
||||||
|
<h3>Coffee</h3>
|
||||||
|
<div class="prop-items" id="coffee-props" role="group" aria-label="Coffee props"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-category">
|
||||||
|
<h3>Sodas</h3>
|
||||||
|
<div class="prop-items" id="soda-props" role="group" aria-label="Soda props"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-category">
|
||||||
|
<h3>Tea</h3>
|
||||||
|
<div class="prop-items" id="tea-props" role="group" aria-label="Tea props"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-category">
|
||||||
|
<h3>Misc</h3>
|
||||||
|
<div class="prop-items" id="misc-props" role="group" aria-label="Miscellaneous props"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-category">
|
||||||
|
<h3>Good Pol</h3>
|
||||||
|
<div class="prop-items" id="goodpol-props" role="group" aria-label="Good Pol props"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Flags Tab -->
|
||||||
|
<section id="flags-tab" class="tab-content" role="tabpanel" aria-labelledby="flags-tab-btn">
|
||||||
|
<div class="selected-prop-container" id="selected-flag" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div class="props-grid">
|
||||||
|
<div class="prop-category">
|
||||||
|
<h3>National Flags</h3>
|
||||||
|
<div class="prop-items" id="flag-items" role="group" aria-label="National flags"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Tab switching
|
||||||
|
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||||
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
function switchToTab(tabId, updateHash = true) {
|
||||||
|
const btn = document.querySelector(`.tab-btn[data-tab="${tabId}"]`);
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
tabBtns.forEach(b => {
|
||||||
|
b.classList.remove('active');
|
||||||
|
b.setAttribute('aria-selected', 'false');
|
||||||
|
});
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
tabContents.forEach(content => {
|
||||||
|
content.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById(`${tabId}-tab`).classList.add('active');
|
||||||
|
|
||||||
|
if (updateHash) {
|
||||||
|
window.location.hash = `tab=${tabId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTabFromHash() {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (!hash) return null;
|
||||||
|
const match = hash.match(/^#tab=(\w+)$/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tabBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
switchToTab(btn.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const tabId = getTabFromHash();
|
||||||
|
if (tabId) {
|
||||||
|
switchToTab(tabId, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avatar functionality
|
||||||
|
let currentColor = '#FFCC00';
|
||||||
|
let currentEmotion = 'smile';
|
||||||
|
|
||||||
|
function lightenColor(hex, percent) {
|
||||||
|
const num = parseInt(hex.replace('#', ''), 16);
|
||||||
|
const amt = Math.round(2.55 * percent);
|
||||||
|
const R = Math.min(255, (num >> 16) + amt);
|
||||||
|
const G = Math.min(255, ((num >> 8) & 0x00FF) + amt);
|
||||||
|
const B = Math.min(255, (num & 0x0000FF) + amt);
|
||||||
|
return `#${(1 << 24 | R << 16 | G << 8 | B).toString(16).slice(1).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkenColor(hex, percent) {
|
||||||
|
const num = parseInt(hex.replace('#', ''), 16);
|
||||||
|
const amt = Math.round(2.55 * percent);
|
||||||
|
const R = Math.max(0, (num >> 16) - amt);
|
||||||
|
const G = Math.max(0, ((num >> 8) & 0x00FF) - amt);
|
||||||
|
const B = Math.max(0, (num & 0x0000FF) - amt);
|
||||||
|
return `#${(1 << 24 | R << 16 | G << 8 | B).toString(16).slice(1).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSVG(url, containerId) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const svgText = await response.text();
|
||||||
|
document.getElementById(containerId).innerHTML = svgText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFaceColor(primaryColor) {
|
||||||
|
currentColor = primaryColor;
|
||||||
|
const faceLayer = document.getElementById('face-layer');
|
||||||
|
const svg = faceLayer.querySelector('svg');
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
const highlight = lightenColor(primaryColor, 25);
|
||||||
|
const shadow = darkenColor(primaryColor, 20);
|
||||||
|
|
||||||
|
svg.style.setProperty('--face-primary', primaryColor);
|
||||||
|
svg.style.setProperty('--face-highlight', highlight);
|
||||||
|
svg.style.setProperty('--face-shadow', shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEmotion(emotion) {
|
||||||
|
currentEmotion = emotion;
|
||||||
|
await loadSVG(`avatar/${emotion}.svg`, 'expression-layer');
|
||||||
|
|
||||||
|
document.querySelectorAll('.emotion-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.emotion === emotion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props functionality
|
||||||
|
const props = {
|
||||||
|
hookah: ['traditional', 'modern', 'mini', 'ornate', 'tall'],
|
||||||
|
coffee: ['espresso', 'latte', 'iced', 'frenchpress', 'pourover', 'turkish', 'cup-empty'],
|
||||||
|
soda: ['cola', 'lemonlime', 'orange', 'grape', 'rootbeer'],
|
||||||
|
tea: ['iced', 'pot', 'cup', 'cup-empty', 'bag'],
|
||||||
|
misc: ['iou', 'signed-dollar', 'thankyou', 'yousuck'],
|
||||||
|
goodpol: ['cccp', 'china', 'palestine']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
const flags = ['chinese'];
|
||||||
|
|
||||||
|
let selectedProp = null;
|
||||||
|
let selectedFlag = null;
|
||||||
|
|
||||||
|
async function loadFlagPreview(name, container) {
|
||||||
|
const filename = `flags/${name}.svg`;
|
||||||
|
const response = await fetch(filename);
|
||||||
|
const svgText = await response.text();
|
||||||
|
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.className = 'prop-card';
|
||||||
|
card.dataset.flag = name;
|
||||||
|
card.setAttribute('aria-label', `${name} flag`);
|
||||||
|
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'prop-preview';
|
||||||
|
preview.innerHTML = svgText;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'prop-name';
|
||||||
|
label.textContent = name.replace(/([A-Z])/g, ' $1').trim();
|
||||||
|
|
||||||
|
card.appendChild(preview);
|
||||||
|
card.appendChild(label);
|
||||||
|
|
||||||
|
card.addEventListener('click', () => selectFlag(name, card));
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFlag(name, card) {
|
||||||
|
document.querySelectorAll('#flag-items .prop-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
card.classList.add('selected');
|
||||||
|
|
||||||
|
const filename = `flags/${name}.svg`;
|
||||||
|
const response = await fetch(filename);
|
||||||
|
const svgText = await response.text();
|
||||||
|
document.getElementById('selected-flag').innerHTML = svgText;
|
||||||
|
selectedFlag = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPropPreview(category, name, container) {
|
||||||
|
const filename = `props/${category}-${name}.svg`;
|
||||||
|
const response = await fetch(filename);
|
||||||
|
const svgText = await response.text();
|
||||||
|
|
||||||
|
const card = document.createElement('button');
|
||||||
|
card.className = 'prop-card';
|
||||||
|
card.dataset.prop = `${category}-${name}`;
|
||||||
|
card.setAttribute('aria-label', `${name} ${category}`);
|
||||||
|
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'prop-preview';
|
||||||
|
preview.innerHTML = svgText;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'prop-name';
|
||||||
|
label.textContent = name.replace(/([A-Z])/g, ' $1').trim();
|
||||||
|
|
||||||
|
card.appendChild(preview);
|
||||||
|
card.appendChild(label);
|
||||||
|
|
||||||
|
card.addEventListener('click', () => selectProp(category, name, card));
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectProp(category, name, card) {
|
||||||
|
document.querySelectorAll('.prop-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
card.classList.add('selected');
|
||||||
|
|
||||||
|
const filename = `props/${category}-${name}.svg`;
|
||||||
|
const response = await fetch(filename);
|
||||||
|
const svgText = await response.text();
|
||||||
|
document.getElementById('selected-prop').innerHTML = svgText;
|
||||||
|
selectedProp = `${category}-${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initProps() {
|
||||||
|
const hookahContainer = document.getElementById('hookah-props');
|
||||||
|
const coffeeContainer = document.getElementById('coffee-props');
|
||||||
|
const sodaContainer = document.getElementById('soda-props');
|
||||||
|
const teaContainer = document.getElementById('tea-props');
|
||||||
|
const miscContainer = document.getElementById('misc-props');
|
||||||
|
const goodpolContainer = document.getElementById('goodpol-props');
|
||||||
|
|
||||||
|
for (const name of props.hookah) {
|
||||||
|
await loadPropPreview('hookah', name, hookahContainer);
|
||||||
|
}
|
||||||
|
for (const name of props.coffee) {
|
||||||
|
await loadPropPreview('coffee', name, coffeeContainer);
|
||||||
|
}
|
||||||
|
for (const name of props.soda) {
|
||||||
|
await loadPropPreview('soda', name, sodaContainer);
|
||||||
|
}
|
||||||
|
for (const name of props.tea) {
|
||||||
|
await loadPropPreview('tea', name, teaContainer);
|
||||||
|
}
|
||||||
|
for (const name of props.misc) {
|
||||||
|
await loadPropPreview('misc', name, miscContainer);
|
||||||
|
}
|
||||||
|
for (const name of props.goodpol) {
|
||||||
|
await loadPropPreview('goodpol', name, goodpolContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select first prop by default
|
||||||
|
const firstCard = document.querySelector('#props-tab .prop-card');
|
||||||
|
if (firstCard) {
|
||||||
|
selectProp('hookah', 'traditional', firstCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load flags
|
||||||
|
const flagContainer = document.getElementById('flag-items');
|
||||||
|
for (const name of flags) {
|
||||||
|
await loadFlagPreview(name, flagContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select first flag by default
|
||||||
|
const firstFlagCard = document.querySelector('#flag-items .prop-card');
|
||||||
|
if (firstFlagCard) {
|
||||||
|
selectFlag('chinese', firstFlagCard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
async function init() {
|
||||||
|
// Check for tab in URL hash and switch to it
|
||||||
|
const initialTab = getTabFromHash();
|
||||||
|
if (initialTab) {
|
||||||
|
switchToTab(initialTab, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
loadSVG('avatar/face.svg', 'face-layer'),
|
||||||
|
loadSVG('avatar/smile.svg', 'expression-layer')
|
||||||
|
]);
|
||||||
|
|
||||||
|
updateFaceColor('#FFCC00');
|
||||||
|
|
||||||
|
const colorPicker = document.getElementById('colorPicker');
|
||||||
|
colorPicker.addEventListener('input', (e) => {
|
||||||
|
updateFaceColor(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const color = btn.dataset.color;
|
||||||
|
colorPicker.value = color;
|
||||||
|
updateFaceColor(color);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.emotion-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
setEmotion(btn.dataset.emotion);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if ((key >= '0' && key <= '9') || key === 'c' || key === 'l') {
|
||||||
|
const btn = document.querySelector(`.emotion-btn[data-key="${key}"]`);
|
||||||
|
if (btn) {
|
||||||
|
setEmotion(btn.dataset.emotion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProps();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
stock/load.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Load all stock assets into the server.
|
||||||
|
#
|
||||||
|
# Usage: ./stock/load.sh [--force|-f]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --force, -f Update existing assets instead of failing with 409 Conflict
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
FORCE_FLAG=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force|-f)
|
||||||
|
FORCE_FLAG="--force"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
(cd avatar && ./upload-stockavatars.sh $FORCE_FLAG)
|
||||||
|
(cd props && ./upload-stockprops.sh $FORCE_FLAG)
|
||||||
22
stock/props/coffee-cup-empty.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="whitecup" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#E8E8E8"/>
|
||||||
|
<stop offset="50%" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="100%" stop-color="#E8E8E8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Saucer -->
|
||||||
|
<ellipse cx="24" cy="42" rx="14" ry="4" fill="url(#whitecup)"/>
|
||||||
|
<ellipse cx="24" cy="41" rx="12" ry="3" fill="#F5F5F5"/>
|
||||||
|
<!-- Cup body -->
|
||||||
|
<path d="M 14 28 L 16 40 L 32 40 L 34 28 Z" fill="url(#whitecup)"/>
|
||||||
|
<!-- Cup rim -->
|
||||||
|
<ellipse cx="24" cy="28" rx="10" ry="3" fill="url(#whitecup)"/>
|
||||||
|
<!-- Empty cup interior -->
|
||||||
|
<ellipse cx="24" cy="29" rx="8" ry="2.5" fill="#F0F0F0"/>
|
||||||
|
<!-- Handle -->
|
||||||
|
<path d="M 34 30 Q 40 30 40 35 Q 40 40 34 38" fill="none" stroke="url(#whitecup)" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 950 B |
30
stock/props/coffee-espresso.svg
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="whitecup" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#E8E8E8"/>
|
||||||
|
<stop offset="50%" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="100%" stop-color="#E8E8E8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="espresso" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#8B4513"/>
|
||||||
|
<stop offset="100%" stop-color="#3E1F0D"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Saucer -->
|
||||||
|
<ellipse cx="24" cy="42" rx="14" ry="4" fill="url(#whitecup)"/>
|
||||||
|
<ellipse cx="24" cy="41" rx="12" ry="3" fill="#F5F5F5"/>
|
||||||
|
<!-- Cup body -->
|
||||||
|
<path d="M 14 28 L 16 40 L 32 40 L 34 28 Z" fill="url(#whitecup)"/>
|
||||||
|
<!-- Cup rim -->
|
||||||
|
<ellipse cx="24" cy="28" rx="10" ry="3" fill="url(#whitecup)"/>
|
||||||
|
<!-- Coffee surface with crema -->
|
||||||
|
<ellipse cx="24" cy="29" rx="8" ry="2.5" fill="url(#espresso)"/>
|
||||||
|
<ellipse cx="22" cy="28.5" rx="4" ry="1.5" fill="#D4A574" opacity="0.7"/>
|
||||||
|
<!-- Handle -->
|
||||||
|
<path d="M 34 30 Q 40 30 40 35 Q 40 40 34 38" fill="none" stroke="url(#whitecup)" stroke-width="3"/>
|
||||||
|
<!-- Steam -->
|
||||||
|
<path d="M 20 24 Q 19 20 21 18" fill="none" stroke="#CCC" stroke-width="1" opacity="0.5"/>
|
||||||
|
<path d="M 26 24 Q 27 19 25 16" fill="none" stroke="#CCC" stroke-width="1" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
32
stock/props/coffee-frenchpress.svg
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="glasspress" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#C0C0C0"/>
|
||||||
|
<stop offset="50%" stop-color="#E8E8E8"/>
|
||||||
|
<stop offset="100%" stop-color="#C0C0C0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="brewedcoffee" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#4A2C17"/>
|
||||||
|
<stop offset="100%" stop-color="#2C1810"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Glass carafe -->
|
||||||
|
<rect x="12" y="14" width="20" height="28" rx="2" fill="url(#glasspress)" opacity="0.4"/>
|
||||||
|
<!-- Coffee inside -->
|
||||||
|
<rect x="14" y="20" width="16" height="20" fill="url(#brewedcoffee)" opacity="0.8"/>
|
||||||
|
<!-- Metal frame -->
|
||||||
|
<rect x="10" y="12" width="24" height="3" rx="1" fill="#808080"/>
|
||||||
|
<rect x="10" y="40" width="24" height="4" rx="1" fill="#808080"/>
|
||||||
|
<!-- Plunger rod -->
|
||||||
|
<rect x="22" y="2" width="3" height="16" fill="#696969"/>
|
||||||
|
<!-- Plunger handle -->
|
||||||
|
<ellipse cx="23.5" cy="3" rx="5" ry="2" fill="#404040"/>
|
||||||
|
<!-- Plunger filter -->
|
||||||
|
<rect x="14" y="16" width="16" height="2" fill="#A0A0A0"/>
|
||||||
|
<!-- Handle -->
|
||||||
|
<path d="M 32 18 Q 40 18 40 28 Q 40 38 32 38" fill="none" stroke="#606060" stroke-width="3"/>
|
||||||
|
<!-- Spout hint -->
|
||||||
|
<path d="M 12 16 L 8 14 L 8 18 Z" fill="#808080"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
32
stock/props/coffee-iced.svg
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="plasticcup" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#E0E0E0"/>
|
||||||
|
<stop offset="50%" stop-color="#F8F8F8"/>
|
||||||
|
<stop offset="100%" stop-color="#E0E0E0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="icedcoffee" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#C4A77D"/>
|
||||||
|
<stop offset="100%" stop-color="#6B4423"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Plastic cup -->
|
||||||
|
<path d="M 12 10 L 16 44 L 32 44 L 36 10 Z" fill="url(#plasticcup)" opacity="0.5"/>
|
||||||
|
<!-- Iced coffee liquid -->
|
||||||
|
<path d="M 14 14 L 17 42 L 31 42 L 34 14 Z" fill="url(#icedcoffee)" opacity="0.8"/>
|
||||||
|
<!-- Ice cubes -->
|
||||||
|
<rect x="18" y="18" width="6" height="5" rx="1" fill="#E0FFFF" opacity="0.7"/>
|
||||||
|
<rect x="26" y="22" width="5" height="5" rx="1" fill="#E0FFFF" opacity="0.7"/>
|
||||||
|
<rect x="19" y="28" width="5" height="4" rx="1" fill="#E0FFFF" opacity="0.7"/>
|
||||||
|
<!-- Dome lid -->
|
||||||
|
<ellipse cx="24" cy="10" rx="12" ry="3" fill="url(#plasticcup)"/>
|
||||||
|
<path d="M 12 10 Q 24 4 36 10" fill="url(#plasticcup)" opacity="0.8"/>
|
||||||
|
<!-- Straw -->
|
||||||
|
<rect x="28" y="2" width="3" height="20" fill="#32CD32"/>
|
||||||
|
<ellipse cx="29.5" cy="2" rx="1.5" ry="0.5" fill="#228B22"/>
|
||||||
|
<!-- Condensation drops -->
|
||||||
|
<circle cx="14" cy="25" r="1" fill="#87CEEB" opacity="0.6"/>
|
||||||
|
<circle cx="34" cy="32" r="1" fill="#87CEEB" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
31
stock/props/coffee-latte.svg
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tallglass" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#D3D3D3"/>
|
||||||
|
<stop offset="50%" stop-color="#F0F0F0"/>
|
||||||
|
<stop offset="100%" stop-color="#D3D3D3"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lattemilk" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFF8DC"/>
|
||||||
|
<stop offset="50%" stop-color="#D4A574"/>
|
||||||
|
<stop offset="100%" stop-color="#8B4513"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Tall glass mug -->
|
||||||
|
<path d="M 14 14 L 16 44 L 32 44 L 34 14 Z" fill="url(#tallglass)" opacity="0.6"/>
|
||||||
|
<!-- Layered latte -->
|
||||||
|
<rect x="16" y="34" width="16" height="8" fill="#5D3A1A"/>
|
||||||
|
<rect x="16" y="22" width="16" height="12" fill="#D4A574"/>
|
||||||
|
<rect x="16" y="16" width="16" height="6" fill="#FFFAF0"/>
|
||||||
|
<!-- Foam top -->
|
||||||
|
<ellipse cx="24" cy="16" rx="9" ry="3" fill="#FFFAF0"/>
|
||||||
|
<!-- Latte art heart -->
|
||||||
|
<path d="M 22 16 Q 24 14 26 16 L 24 19 Z" fill="#8B4513" opacity="0.6"/>
|
||||||
|
<!-- Handle -->
|
||||||
|
<path d="M 34 20 Q 42 20 42 30 Q 42 40 34 38" fill="none" stroke="url(#tallglass)" stroke-width="3"/>
|
||||||
|
<!-- Steam wisps -->
|
||||||
|
<path d="M 22 12 Q 21 8 23 6" fill="none" stroke="#DDD" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
<path d="M 26 12 Q 27 7 25 4" fill="none" stroke="#DDD" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
31
stock/props/coffee-pourover.svg
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ceramic" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#E8E8E8"/>
|
||||||
|
<stop offset="50%" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="100%" stop-color="#E8E8E8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="drippingcoffee" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#5D3A1A"/>
|
||||||
|
<stop offset="100%" stop-color="#3E1F0D"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Carafe/server -->
|
||||||
|
<path d="M 14 30 L 16 46 L 32 46 L 34 30 Z" fill="url(#ceramic)" opacity="0.6"/>
|
||||||
|
<rect x="16" y="34" width="16" height="10" fill="url(#drippingcoffee)" opacity="0.8"/>
|
||||||
|
<!-- Pour over dripper -->
|
||||||
|
<path d="M 10 12 L 18 28 L 30 28 L 38 12 Z" fill="url(#ceramic)"/>
|
||||||
|
<ellipse cx="24" cy="12" rx="14" ry="4" fill="url(#ceramic)"/>
|
||||||
|
<!-- Filter visible inside -->
|
||||||
|
<path d="M 14 14 L 20 26 L 28 26 L 34 14 Z" fill="#F5DEB3" opacity="0.6"/>
|
||||||
|
<!-- Coffee grounds -->
|
||||||
|
<ellipse cx="24" cy="16" rx="8" ry="2" fill="#3E1F0D"/>
|
||||||
|
<!-- Drip stream -->
|
||||||
|
<rect x="23" y="28" width="2" height="4" fill="#5D3A1A"/>
|
||||||
|
<!-- Water being poured -->
|
||||||
|
<path d="M 28 6 Q 26 8 24 14" fill="none" stroke="#87CEEB" stroke-width="2" opacity="0.5"/>
|
||||||
|
<!-- Steam from carafe -->
|
||||||
|
<path d="M 22 32 Q 21 30 23 28" fill="none" stroke="#CCC" stroke-width="0.8" opacity="0.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
49
stock/props/coffee-turkish.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bronze" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#8B5A2B"/>
|
||||||
|
<stop offset="25%" stop-color="#CD853F"/>
|
||||||
|
<stop offset="50%" stop-color="#DEB887"/>
|
||||||
|
<stop offset="75%" stop-color="#CD853F"/>
|
||||||
|
<stop offset="100%" stop-color="#8B5A2B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bronzedark" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#5C3317"/>
|
||||||
|
<stop offset="50%" stop-color="#8B4513"/>
|
||||||
|
<stop offset="100%" stop-color="#5C3317"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="turkishcoffee" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3E2723"/>
|
||||||
|
<stop offset="100%" stop-color="#1A0F0A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bronzehandle" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#CD853F"/>
|
||||||
|
<stop offset="50%" stop-color="#8B5A2B"/>
|
||||||
|
<stop offset="100%" stop-color="#5C3317"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Pot body - tapered cezve shape -->
|
||||||
|
<path d="M 16 38 Q 14 34 16 28 L 18 22 Q 24 20 30 22 L 32 28 Q 34 34 32 38 Z" fill="url(#bronze)"/>
|
||||||
|
<!-- Bottom rim -->
|
||||||
|
<ellipse cx="24" cy="38" rx="8" ry="2.5" fill="url(#bronzedark)"/>
|
||||||
|
<!-- Top rim - flared lip -->
|
||||||
|
<ellipse cx="24" cy="22" rx="7" ry="2.5" fill="url(#bronze)"/>
|
||||||
|
<ellipse cx="24" cy="21.5" rx="6" ry="2" fill="url(#bronzedark)"/>
|
||||||
|
<!-- Coffee surface -->
|
||||||
|
<ellipse cx="24" cy="23" rx="5" ry="1.5" fill="url(#turkishcoffee)"/>
|
||||||
|
<!-- Foam/crema ring -->
|
||||||
|
<ellipse cx="24" cy="23" rx="5" ry="1.5" fill="none" stroke="#6D4C41" stroke-width="0.5"/>
|
||||||
|
<!-- Long handle -->
|
||||||
|
<path d="M 32 28 L 44 20" fill="none" stroke="url(#bronzehandle)" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M 32 28 L 44 20" fill="none" stroke="#DEB887" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
|
||||||
|
<!-- Handle end knob -->
|
||||||
|
<circle cx="45" cy="19" r="2" fill="url(#bronzedark)"/>
|
||||||
|
<circle cx="44.5" cy="18.5" r="1" fill="#CD853F" opacity="0.6"/>
|
||||||
|
<!-- Highlight on body -->
|
||||||
|
<path d="M 19 26 L 19 34" stroke="#FFF" stroke-width="0.8" opacity="0.25"/>
|
||||||
|
<!-- Steam -->
|
||||||
|
<path d="M 22 19 Q 20 15 22 12" fill="none" stroke="#CCC" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
<path d="M 26 19 Q 28 14 26 10" fill="none" stroke="#CCC" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
33
stock/props/goodpol-cccp.svg
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<filter id="flagshadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1" flood-color="#000000" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="flagclip">
|
||||||
|
<path d="M 6 12 Q 14 10 24 12 Q 34 14 42 12 L 42 36 Q 34 38 24 36 Q 14 34 6 36 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<!-- Flag with wave effect -->
|
||||||
|
<g filter="url(#flagshadow)">
|
||||||
|
<!-- Red background -->
|
||||||
|
<path d="M 6 12 Q 14 10 24 12 Q 34 14 42 12 L 42 36 Q 34 38 24 36 Q 14 34 6 36 Z" fill="#CC0000"/>
|
||||||
|
<!-- Emblem clipped to flag -->
|
||||||
|
<g clip-path="url(#flagclip)">
|
||||||
|
<!-- Hammer and sickle with star - positioned in upper-left canton -->
|
||||||
|
<!-- Source emblem bbox: x=120-270, y=37-260. Target: ~5x9 units at position (8,13) -->
|
||||||
|
<g transform="translate(5.0, 12.1) scale(0.045)">
|
||||||
|
<!-- Star (outlined) -->
|
||||||
|
<path d="m 200,37.5 -8.42,25.91 H 164.34 L 186.38,79.43 177.96,105.34 200,89.32 222.04,105.34 213.62,79.43 235.67,63.41 h -27.25 z m 0,13.5 5.39,16.58 h 17.44 l -14.11,10.25 5.39,16.58 L 200,84.17 185.89,94.42 191.28,77.83 177.18,67.58 h 17.44 z" fill="#FFD700"/>
|
||||||
|
<!-- Hammer -->
|
||||||
|
<path d="m 137.44,171.69 18.86,18.99 17.79,-17.67 c 27.06,29.02 55.44,57 82.29,86.13 4.03,4.06 10.6,4.08 14.66,0.05 4.06,-4.03 4.08,-10.6 0.05,-14.66 -28.82,-27.19 -57.73,-54.6 -86.55,-81.89 l 23.96,-23.8 -33.34,-4.62 z" fill="#FFD700"/>
|
||||||
|
<!-- Sickle -->
|
||||||
|
<path d="m 198.29,110.2 c 15.52,8.74 27.3,21.28 34.25,34.39 7.04,13.29 10.14,27.16 10.2,38.25 0.13,22.74 -18.44,41.18 -41.18,41.18 -12.14,0 -23.05,-5.25 -30.58,-13.6 l -4.17,3.51 c -0.71,-0.27 -1.46,-0.41 -2.22,-0.41 -1.83,0 -3.57,0.81 -4.75,2.2 -2.97,0.39 -5.46,2.45 -6.4,5.29 -3.13,6.29 -8.64,11.22 -15.29,13.48 -0.06,0.02 -0.12,0.05 -0.18,0.08 -3.08,1.13 -6.16,3.16 -8.79,5.8 -5.19,5.24 -7.73,11.94 -6.3,16.64 -0.14,0.41 -0.21,0.84 -0.21,1.27 0,2.17 1.76,3.93 3.93,3.93 0.54,0 1.08,-0.12 1.58,-0.34 4.69,1.06 11.07,-1.55 16.05,-6.56 2.83,-2.85 4.94,-6.22 5.98,-9.53 2.32,-6.62 7.3,-12.02 13.62,-15.05 0.15,-0.07 0.27,-0.15 0.38,-0.22 2.12,-1.01 3.67,-2.93 4.23,-5.21 9.7,11.44 24.25,18.75 40.52,19.14 29.83,0.7 52.13,-21.26 53.16,-52.84 0.52,-15.89 -5.63,-36.38 -19.64,-53.19 -10.71,-12.84 -26.41,-23.51 -44.19,-28.21 z" fill="#FFD700"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<!-- Flag pole -->
|
||||||
|
<rect x="4" y="8" width="2" height="34" rx="0.5" fill="#8B4513"/>
|
||||||
|
<circle cx="5" cy="8" r="2" fill="#FFD700"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
24
stock/props/goodpol-china.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<filter id="flagshadowcn" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1" flood-color="#000000" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<!-- Flag with wave effect -->
|
||||||
|
<g filter="url(#flagshadowcn)">
|
||||||
|
<!-- Red background -->
|
||||||
|
<path d="M 6 12 Q 14 10 24 12 Q 34 14 42 12 L 42 36 Q 34 38 24 36 Q 14 34 6 36 Z" fill="#DE2910"/>
|
||||||
|
<!-- Large star - top left -->
|
||||||
|
<polygon points="12,15 13.2,18 16.5,18 13.8,20 14.8,23 12,21 9.2,23 10.2,20 7.5,18 10.8,18" fill="#FFDE00"/>
|
||||||
|
<!-- Small stars (4 of them arcing around big star) -->
|
||||||
|
<polygon points="20,13 20.5,14.2 21.8,14.2 20.8,15 21.1,16.3 20,15.5 18.9,16.3 19.2,15 18.2,14.2 19.5,14.2" fill="#FFDE00"/>
|
||||||
|
<polygon points="23,16 23.5,17.2 24.8,17.2 23.8,18 24.1,19.3 23,18.5 21.9,19.3 22.2,18 21.2,17.2 22.5,17.2" fill="#FFDE00"/>
|
||||||
|
<polygon points="23,21 23.5,22.2 24.8,22.2 23.8,23 24.1,24.3 23,23.5 21.9,24.3 22.2,23 21.2,22.2 22.5,22.2" fill="#FFDE00"/>
|
||||||
|
<polygon points="20,24 20.5,25.2 21.8,25.2 20.8,26 21.1,27.3 20,26.5 18.9,27.3 19.2,26 18.2,25.2 19.5,25.2" fill="#FFDE00"/>
|
||||||
|
</g>
|
||||||
|
<!-- Flag pole -->
|
||||||
|
<rect x="4" y="8" width="2" height="34" rx="0.5" fill="#8B4513"/>
|
||||||
|
<circle cx="5" cy="8" r="2" fill="#FFD700"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
29
stock/props/goodpol-palestine.svg
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<filter id="flagshadowps" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1" flood-color="#000000" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="flagclip">
|
||||||
|
<path d="M 6 12 Q 14 10 24 12 Q 34 14 42 12 L 42 36 Q 34 38 24 36 Q 14 34 6 36 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<!-- Flag with wave effect -->
|
||||||
|
<g filter="url(#flagshadowps)" clip-path="url(#flagclip)">
|
||||||
|
<!-- Black stripe (top) -->
|
||||||
|
<rect x="4" y="10" width="40" height="8" fill="#000000"/>
|
||||||
|
<!-- White stripe (middle) -->
|
||||||
|
<rect x="4" y="18" width="40" height="8" fill="#FFFFFF"/>
|
||||||
|
<!-- Green stripe (bottom) -->
|
||||||
|
<rect x="4" y="26" width="40" height="12" fill="#007A3D"/>
|
||||||
|
<!-- Red triangle -->
|
||||||
|
<polygon points="6,12 6,36 20,24" fill="#CE1126"/>
|
||||||
|
</g>
|
||||||
|
<!-- Flag outline for wave shape -->
|
||||||
|
<path d="M 6 12 Q 14 10 24 12 Q 34 14 42 12 L 42 36 Q 34 38 24 36 Q 14 34 6 36 Z"
|
||||||
|
fill="none" stroke="none"/>
|
||||||
|
<!-- Flag pole -->
|
||||||
|
<rect x="4" y="8" width="2" height="34" rx="0.5" fill="#8B4513"/>
|
||||||
|
<circle cx="5" cy="8" r="2" fill="#FFD700"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
29
stock/props/hookah-mini.svg
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="miniglass" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#20B2AA"/>
|
||||||
|
<stop offset="50%" stop-color="#48D1CC"/>
|
||||||
|
<stop offset="100%" stop-color="#20B2AA"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="minibrass" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#B8860B"/>
|
||||||
|
<stop offset="50%" stop-color="#DAA520"/>
|
||||||
|
<stop offset="100%" stop-color="#B8860B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Compact round base -->
|
||||||
|
<circle cx="24" cy="36" r="8" fill="url(#miniglass)" opacity="0.8"/>
|
||||||
|
<ellipse cx="24" cy="33" rx="5" ry="3" fill="#7FFFD4" opacity="0.4"/>
|
||||||
|
<!-- Short stem -->
|
||||||
|
<rect x="22" y="22" width="4" height="12" fill="url(#minibrass)"/>
|
||||||
|
<!-- Small bowl -->
|
||||||
|
<ellipse cx="24" cy="20" rx="4" ry="2.5" fill="url(#minibrass)"/>
|
||||||
|
<ellipse cx="24" cy="19" rx="3" ry="1.5" fill="#654321"/>
|
||||||
|
<!-- Short hose -->
|
||||||
|
<path d="M 28 32 Q 34 32 36 36" fill="none" stroke="#2F4F4F" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<circle cx="37" cy="37" r="1.5" fill="url(#minibrass)"/>
|
||||||
|
<!-- Smoke wisps -->
|
||||||
|
<path d="M 24 17 Q 25 14 24 12" fill="none" stroke="#DDD" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
29
stock/props/hookah-modern.svg
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="chrome" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#C0C0C0"/>
|
||||||
|
<stop offset="50%" stop-color="#E8E8E8"/>
|
||||||
|
<stop offset="100%" stop-color="#C0C0C0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="purpleglass" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#9400D3"/>
|
||||||
|
<stop offset="50%" stop-color="#BA55D3"/>
|
||||||
|
<stop offset="100%" stop-color="#9400D3"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Modern cylindrical base -->
|
||||||
|
<rect x="16" y="30" width="16" height="14" rx="3" fill="url(#purpleglass)" opacity="0.8"/>
|
||||||
|
<rect x="18" y="32" width="12" height="6" fill="#DDA0DD" opacity="0.4"/>
|
||||||
|
<!-- Slim stem -->
|
||||||
|
<rect x="22" y="10" width="4" height="20" fill="url(#chrome)"/>
|
||||||
|
<!-- Modern bowl -->
|
||||||
|
<path d="M 18 10 L 20 6 L 28 6 L 30 10 Z" fill="#333"/>
|
||||||
|
<ellipse cx="24" cy="6" rx="4" ry="1.5" fill="#555"/>
|
||||||
|
<!-- LED ring -->
|
||||||
|
<ellipse cx="24" cy="44" rx="8" ry="2" fill="#00FF00" opacity="0.6"/>
|
||||||
|
<!-- Silicone hose -->
|
||||||
|
<path d="M 32 35 Q 38 35 40 40" fill="none" stroke="#333" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<ellipse cx="41" cy="41" rx="2" ry="1" fill="url(#chrome)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
34
stock/props/hookah-ornate.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gold" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#FFD700"/>
|
||||||
|
<stop offset="50%" stop-color="#FFF8DC"/>
|
||||||
|
<stop offset="100%" stop-color="#FFD700"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ruby" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#8B0000"/>
|
||||||
|
<stop offset="50%" stop-color="#DC143C"/>
|
||||||
|
<stop offset="100%" stop-color="#8B0000"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Ornate vase base -->
|
||||||
|
<path d="M 14 44 Q 14 38 18 34 Q 24 30 30 34 Q 34 38 34 44 Z" fill="url(#ruby)" opacity="0.85"/>
|
||||||
|
<ellipse cx="24" cy="36" rx="5" ry="2" fill="#FF6B6B" opacity="0.4"/>
|
||||||
|
<!-- Decorative band -->
|
||||||
|
<ellipse cx="24" cy="34" rx="8" ry="1.5" fill="url(#gold)"/>
|
||||||
|
<!-- Ornate stem with bulge -->
|
||||||
|
<rect x="22" y="16" width="4" height="16" fill="url(#gold)"/>
|
||||||
|
<ellipse cx="24" cy="24" rx="3" ry="2" fill="url(#gold)"/>
|
||||||
|
<!-- Crown-style bowl -->
|
||||||
|
<path d="M 18 14 L 19 10 L 21 12 L 24 8 L 27 12 L 29 10 L 30 14 Z" fill="url(#gold)"/>
|
||||||
|
<ellipse cx="24" cy="14" rx="6" ry="2" fill="url(#gold)"/>
|
||||||
|
<ellipse cx="24" cy="13" rx="4" ry="1.5" fill="#4A2000"/>
|
||||||
|
<!-- Gem accents -->
|
||||||
|
<circle cx="18" cy="38" r="1" fill="#00CED1"/>
|
||||||
|
<circle cx="30" cy="38" r="1" fill="#00CED1"/>
|
||||||
|
<!-- Decorative hose -->
|
||||||
|
<path d="M 34 36 Q 40 34 42 38" fill="none" stroke="#8B0000" stroke-width="2"/>
|
||||||
|
<circle cx="43" cy="39" r="2" fill="url(#gold)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
33
stock/props/hookah-tall.svg
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="blackmetal" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#1C1C1C"/>
|
||||||
|
<stop offset="50%" stop-color="#3C3C3C"/>
|
||||||
|
<stop offset="100%" stop-color="#1C1C1C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="clearglass" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#E0E0E0"/>
|
||||||
|
<stop offset="50%" stop-color="#F5F5F5"/>
|
||||||
|
<stop offset="100%" stop-color="#E0E0E0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Tall narrow base -->
|
||||||
|
<path d="M 18 46 L 20 32 L 28 32 L 30 46 Z" fill="url(#clearglass)" opacity="0.7"/>
|
||||||
|
<ellipse cx="24" cy="38" rx="4" ry="2" fill="#B0E0E6" opacity="0.5"/>
|
||||||
|
<!-- Long slim stem -->
|
||||||
|
<rect x="22" y="6" width="4" height="26" fill="url(#blackmetal)"/>
|
||||||
|
<!-- Diffuser at bottom of stem -->
|
||||||
|
<ellipse cx="24" cy="32" rx="3" ry="1" fill="url(#blackmetal)"/>
|
||||||
|
<!-- Sleek bowl -->
|
||||||
|
<ellipse cx="24" cy="5" rx="5" ry="2.5" fill="url(#blackmetal)"/>
|
||||||
|
<ellipse cx="24" cy="4" rx="3.5" ry="1.5" fill="#2F1810"/>
|
||||||
|
<!-- Heat management device -->
|
||||||
|
<rect x="20" y="2" width="8" height="2" rx="1" fill="#666"/>
|
||||||
|
<!-- Slim hose -->
|
||||||
|
<path d="M 30 36 Q 36 36 38 42" fill="none" stroke="#1C1C1C" stroke-width="1.5"/>
|
||||||
|
<ellipse cx="39" cy="43" rx="1.5" ry="1" fill="url(#blackmetal)"/>
|
||||||
|
<!-- Subtle smoke -->
|
||||||
|
<path d="M 24 1 Q 26 -2 24 -4" fill="none" stroke="#CCC" stroke-width="0.7" opacity="0.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
33
stock/props/hookah-traditional.svg
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="brass" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#CD7F32"/>
|
||||||
|
<stop offset="50%" stop-color="#DAA520"/>
|
||||||
|
<stop offset="100%" stop-color="#CD7F32"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="glass" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#4169E1"/>
|
||||||
|
<stop offset="50%" stop-color="#1E90FF"/>
|
||||||
|
<stop offset="100%" stop-color="#4169E1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Base/water chamber -->
|
||||||
|
<ellipse cx="24" cy="38" rx="10" ry="5" fill="url(#glass)" opacity="0.8"/>
|
||||||
|
<ellipse cx="24" cy="34" rx="8" ry="12" fill="url(#glass)" opacity="0.7"/>
|
||||||
|
<ellipse cx="24" cy="30" rx="6" ry="3" fill="#87CEEB" opacity="0.5"/>
|
||||||
|
<!-- Stem -->
|
||||||
|
<rect x="22" y="12" width="4" height="18" fill="url(#brass)"/>
|
||||||
|
<!-- Bowl -->
|
||||||
|
<ellipse cx="24" cy="10" rx="5" ry="3" fill="url(#brass)"/>
|
||||||
|
<ellipse cx="24" cy="8" rx="4" ry="2" fill="#8B4513"/>
|
||||||
|
<!-- Hose connector -->
|
||||||
|
<circle cx="32" cy="28" r="2" fill="url(#brass)"/>
|
||||||
|
<!-- Hose -->
|
||||||
|
<path d="M 34 28 Q 40 32 42 38" fill="none" stroke="#8B0000" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- Mouthpiece -->
|
||||||
|
<ellipse cx="42" cy="40" rx="2" ry="1" fill="url(#brass)"/>
|
||||||
|
<!-- Smoke -->
|
||||||
|
<path d="M 24 6 Q 26 2 24 0" fill="none" stroke="#CCCCCC" stroke-width="1" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
34
stock/props/misc-iou.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paper" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFFEF0"/>
|
||||||
|
<stop offset="100%" stop-color="#F5F5DC"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="papershadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.25"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<!-- Paper note with slight rotation -->
|
||||||
|
<g transform="rotate(-5, 24, 24)">
|
||||||
|
<!-- Paper background -->
|
||||||
|
<rect x="8" y="8" width="32" height="32" rx="2" fill="url(#paper)" filter="url(#papershadow)"/>
|
||||||
|
<!-- Torn edge effect at top -->
|
||||||
|
<path d="M 8 10 L 10 8 L 14 10 L 18 8 L 22 10 L 26 8 L 30 10 L 34 8 L 38 10 L 40 8" fill="none" stroke="#E8E8D0" stroke-width="1"/>
|
||||||
|
<!-- Red lines like notebook paper -->
|
||||||
|
<line x1="10" y1="18" x2="38" y2="18" stroke="#FFB6C1" stroke-width="0.5" opacity="0.5"/>
|
||||||
|
<line x1="10" y1="24" x2="38" y2="24" stroke="#FFB6C1" stroke-width="0.5" opacity="0.5"/>
|
||||||
|
<line x1="10" y1="30" x2="38" y2="30" stroke="#FFB6C1" stroke-width="0.5" opacity="0.5"/>
|
||||||
|
<!-- IOU text -->
|
||||||
|
<text x="24" y="22" font-family="Georgia, serif" font-size="12" font-weight="bold" fill="#1a1a2e" text-anchor="middle">I O U</text>
|
||||||
|
<!-- Underline -->
|
||||||
|
<line x1="14" y1="25" x2="34" y2="25" stroke="#1a1a2e" stroke-width="1"/>
|
||||||
|
<!-- Small signature line -->
|
||||||
|
<line x1="20" y1="34" x2="36" y2="34" stroke="#333" stroke-width="0.5"/>
|
||||||
|
<text x="18" y="35" font-family="Arial, sans-serif" font-size="3" fill="#666">x</text>
|
||||||
|
</g>
|
||||||
|
<!-- Pushpin -->
|
||||||
|
<circle cx="24" cy="6" r="3" fill="#DC143C"/>
|
||||||
|
<ellipse cx="24" cy="6" rx="1.5" ry="1" fill="#FF6B6B"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
44
stock/props/misc-signed-dollar.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="dollarbg" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#85BB65"/>
|
||||||
|
<stop offset="50%" stop-color="#6B9E4E"/>
|
||||||
|
<stop offset="100%" stop-color="#85BB65"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="billshadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1" flood-color="#000000" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<!-- Bill with slight tilt -->
|
||||||
|
<g transform="rotate(-3, 24, 24)">
|
||||||
|
<!-- Bill background -->
|
||||||
|
<rect x="4" y="14" width="40" height="20" rx="1" fill="url(#dollarbg)" filter="url(#billshadow)"/>
|
||||||
|
<!-- Outer ornate border -->
|
||||||
|
<rect x="5" y="15" width="38" height="18" rx="0.5" fill="none" stroke="#2D5016" stroke-width="0.8"/>
|
||||||
|
<!-- Inner border -->
|
||||||
|
<rect x="7" y="17" width="34" height="14" rx="0.5" fill="none" stroke="#2D5016" stroke-width="0.4"/>
|
||||||
|
<!-- Left circle with "1" -->
|
||||||
|
<circle cx="12" cy="24" r="4" fill="none" stroke="#2D5016" stroke-width="0.5"/>
|
||||||
|
<text x="12" y="26" font-family="Georgia, serif" font-size="6" font-weight="bold" fill="#2D5016" text-anchor="middle">1</text>
|
||||||
|
<!-- Right circle with "1" -->
|
||||||
|
<circle cx="36" cy="24" r="4" fill="none" stroke="#2D5016" stroke-width="0.5"/>
|
||||||
|
<text x="36" y="26" font-family="Georgia, serif" font-size="6" font-weight="bold" fill="#2D5016" text-anchor="middle">1</text>
|
||||||
|
<!-- Center portrait placeholder (simplified oval) -->
|
||||||
|
<ellipse cx="24" cy="24" rx="5" ry="6" fill="#7AAF54" stroke="#2D5016" stroke-width="0.4"/>
|
||||||
|
<ellipse cx="24" cy="23" rx="2" ry="2.5" fill="#6B9E4E"/>
|
||||||
|
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#6B9E4E"/>
|
||||||
|
<!-- "ONE DOLLAR" text at bottom -->
|
||||||
|
<text x="24" y="30" font-family="Georgia, serif" font-size="2" fill="#2D5016" text-anchor="middle">ONE DOLLAR</text>
|
||||||
|
<!-- Big sharpie signature "Signed Dollar" across the bill - flowing cursive -->
|
||||||
|
<g transform="rotate(-6, 24, 24)">
|
||||||
|
<!-- "Signed" in connected cursive -->
|
||||||
|
<path d="M 7 24 C 6 21 9 19 10 21 C 11 23 9 25 11 24 C 12 23 13 22 14 24 C 14 25 13 26 15 25 C 16 24 16 22 17 24 C 17 26 18 24 19 23 C 20 22 21 24 21 25 C 21 26 22 24 23 24"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- "Dollar" in connected cursive -->
|
||||||
|
<path d="M 25 24 C 25 21 27 20 28 22 C 29 24 27 26 29 25 C 30 24 31 23 32 25 C 32 26 33 25 34 24 C 35 23 35 25 36 25 C 37 25 37 23 38 24 C 39 25 40 24 41 23"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
27
stock/props/misc-thankyou.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cardpaper" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFF8F0"/>
|
||||||
|
<stop offset="100%" stop-color="#F5E6D3"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="cardshadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.25"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<!-- Card with slight rotation -->
|
||||||
|
<g transform="rotate(3, 24, 24)">
|
||||||
|
<!-- Card background -->
|
||||||
|
<rect x="6" y="10" width="36" height="28" rx="1" fill="url(#cardpaper)" filter="url(#cardshadow)"/>
|
||||||
|
<!-- Decorative border -->
|
||||||
|
<rect x="8" y="12" width="32" height="24" rx="0.5" fill="none" stroke="#D4AF37" stroke-width="0.8"/>
|
||||||
|
<!-- Inner decorative line -->
|
||||||
|
<rect x="10" y="14" width="28" height="20" rx="0.5" fill="none" stroke="#D4AF37" stroke-width="0.3" opacity="0.5"/>
|
||||||
|
<!-- "Thank You" text in script -->
|
||||||
|
<text x="24" y="23" font-family="Georgia, serif" font-size="6" font-style="italic" fill="#8B4513" text-anchor="middle">Thank</text>
|
||||||
|
<text x="24" y="30" font-family="Georgia, serif" font-size="6" font-style="italic" fill="#8B4513" text-anchor="middle">You!</text>
|
||||||
|
<!-- Small heart decoration -->
|
||||||
|
<path d="M 24 16 C 23 15 21 15 21 17 C 21 18 24 20 24 20 C 24 20 27 18 27 17 C 27 15 25 15 24 16" fill="#DC143C" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
27
stock/props/misc-yousuck.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="postit" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFFF88"/>
|
||||||
|
<stop offset="100%" stop-color="#FFEE55"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="postitshadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feDropShadow dx="1" dy="2" stdDeviation="1" flood-color="#000000" flood-opacity="0.2"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<!-- Post-it with curl -->
|
||||||
|
<g transform="rotate(-8, 24, 24)">
|
||||||
|
<!-- Post-it background -->
|
||||||
|
<path d="M 8 8 L 40 8 L 40 38 L 12 38 Q 8 38 8 34 Z" fill="url(#postit)" filter="url(#postitshadow)"/>
|
||||||
|
<!-- Curled corner -->
|
||||||
|
<path d="M 8 34 Q 10 36 12 38 L 8 38 Z" fill="#E8D84A"/>
|
||||||
|
<path d="M 8 34 Q 9 35 12 38" fill="none" stroke="#D4C840" stroke-width="0.5"/>
|
||||||
|
<!-- "YOU" text - angry marker style -->
|
||||||
|
<text x="24" y="20" font-family="Arial Black, sans-serif" font-size="8" font-weight="bold" fill="#CC0000" text-anchor="middle">YOU</text>
|
||||||
|
<!-- "SUCK" text -->
|
||||||
|
<text x="24" y="32" font-family="Arial Black, sans-serif" font-size="8" font-weight="bold" fill="#CC0000" text-anchor="middle">SUCK</text>
|
||||||
|
<!-- Angry underline scribble -->
|
||||||
|
<path d="M 12 34 Q 18 36 24 34 Q 30 32 36 35" fill="none" stroke="#CC0000" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
32
stock/props/soda-cola.svg
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="redcan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#8B0000"/>
|
||||||
|
<stop offset="30%" stop-color="#DC143C"/>
|
||||||
|
<stop offset="70%" stop-color="#DC143C"/>
|
||||||
|
<stop offset="100%" stop-color="#8B0000"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cantop" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#A0A0A0"/>
|
||||||
|
<stop offset="50%" stop-color="#D0D0D0"/>
|
||||||
|
<stop offset="100%" stop-color="#A0A0A0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Can body -->
|
||||||
|
<rect x="12" y="10" width="24" height="34" rx="2" fill="url(#redcan)"/>
|
||||||
|
<!-- Can top -->
|
||||||
|
<ellipse cx="24" cy="10" rx="12" ry="4" fill="url(#cantop)"/>
|
||||||
|
<!-- Pull tab -->
|
||||||
|
<ellipse cx="24" cy="10" rx="4" ry="1.5" fill="#808080"/>
|
||||||
|
<ellipse cx="24" cy="9" rx="2" ry="0.8" fill="#606060"/>
|
||||||
|
<!-- Can bottom rim -->
|
||||||
|
<ellipse cx="24" cy="44" rx="12" ry="3" fill="#A0A0A0"/>
|
||||||
|
<!-- Label stripe -->
|
||||||
|
<rect x="12" y="22" width="24" height="12" fill="#FFFFFF" opacity="0.9"/>
|
||||||
|
<!-- Generic cola text -->
|
||||||
|
<text x="24" y="30" font-family="Arial, sans-serif" font-size="6" font-weight="bold" fill="#8B0000" text-anchor="middle">COLA</text>
|
||||||
|
<!-- Highlight -->
|
||||||
|
<rect x="14" y="12" width="3" height="28" fill="#FFFFFF" opacity="0.2" rx="1"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
34
stock/props/soda-genocide.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cokecan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#8B0000"/>
|
||||||
|
<stop offset="20%" stop-color="#CC0000"/>
|
||||||
|
<stop offset="50%" stop-color="#E60000"/>
|
||||||
|
<stop offset="80%" stop-color="#CC0000"/>
|
||||||
|
<stop offset="100%" stop-color="#8B0000"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="coketop" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#A0A0A0"/>
|
||||||
|
<stop offset="50%" stop-color="#D0D0D0"/>
|
||||||
|
<stop offset="100%" stop-color="#A0A0A0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Can body -->
|
||||||
|
<rect x="12" y="10" width="24" height="34" rx="2" fill="url(#cokecan)"/>
|
||||||
|
<!-- Can top -->
|
||||||
|
<ellipse cx="24" cy="10" rx="12" ry="4" fill="url(#coketop)"/>
|
||||||
|
<!-- Pull tab -->
|
||||||
|
<ellipse cx="24" cy="10" rx="4" ry="1.5" fill="#808080"/>
|
||||||
|
<ellipse cx="24" cy="9" rx="2" ry="0.8" fill="#606060"/>
|
||||||
|
<!-- Can bottom rim -->
|
||||||
|
<ellipse cx="24" cy="44" rx="12" ry="3" fill="#A0A0A0"/>
|
||||||
|
<!-- White wave/ribbon design -->
|
||||||
|
<path d="M 12 24 Q 18 20 24 24 Q 30 28 36 24" fill="none" stroke="#FFFFFF" stroke-width="3" opacity="0.9"/>
|
||||||
|
<path d="M 12 30 Q 18 26 24 30 Q 30 34 36 30" fill="none" stroke="#FFFFFF" stroke-width="2" opacity="0.7"/>
|
||||||
|
<!-- Script text styled like Coca-Cola -->
|
||||||
|
<text x="24" y="28" font-family="Georgia, Times, serif" font-size="5.5" font-style="italic" font-weight="bold" fill="#FFFFFF" text-anchor="middle">Genocide</text>
|
||||||
|
<!-- Highlight -->
|
||||||
|
<rect x="14" y="12" width="3" height="28" fill="#FFFFFF" opacity="0.15" rx="1"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
38
stock/props/soda-grape.svg
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="purplecan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#4B0082"/>
|
||||||
|
<stop offset="30%" stop-color="#8B008B"/>
|
||||||
|
<stop offset="70%" stop-color="#8B008B"/>
|
||||||
|
<stop offset="100%" stop-color="#4B0082"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cantop4" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#A0A0A0"/>
|
||||||
|
<stop offset="50%" stop-color="#D0D0D0"/>
|
||||||
|
<stop offset="100%" stop-color="#A0A0A0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Can body -->
|
||||||
|
<rect x="12" y="10" width="24" height="34" rx="2" fill="url(#purplecan)"/>
|
||||||
|
<!-- Can top -->
|
||||||
|
<ellipse cx="24" cy="10" rx="12" ry="4" fill="url(#cantop4)"/>
|
||||||
|
<!-- Pull tab -->
|
||||||
|
<ellipse cx="24" cy="10" rx="4" ry="1.5" fill="#808080"/>
|
||||||
|
<ellipse cx="24" cy="9" rx="2" ry="0.8" fill="#606060"/>
|
||||||
|
<!-- Can bottom rim -->
|
||||||
|
<ellipse cx="24" cy="44" rx="12" ry="3" fill="#A0A0A0"/>
|
||||||
|
<!-- Grape cluster design -->
|
||||||
|
<circle cx="22" cy="22" r="4" fill="#9932CC" opacity="0.9"/>
|
||||||
|
<circle cx="28" cy="24" r="4" fill="#9932CC" opacity="0.9"/>
|
||||||
|
<circle cx="24" cy="28" r="4" fill="#9932CC" opacity="0.9"/>
|
||||||
|
<circle cx="20" cy="28" r="3" fill="#9932CC" opacity="0.8"/>
|
||||||
|
<circle cx="28" cy="30" r="3" fill="#9932CC" opacity="0.8"/>
|
||||||
|
<!-- Leaf -->
|
||||||
|
<path d="M 26 18 Q 30 16 28 14" fill="#228B22" stroke="#228B22" stroke-width="1"/>
|
||||||
|
<!-- Generic grape text -->
|
||||||
|
<text x="24" y="40" font-family="Arial, sans-serif" font-size="5" font-weight="bold" fill="#FFFFFF" text-anchor="middle">GRAPE</text>
|
||||||
|
<!-- Highlight -->
|
||||||
|
<rect x="14" y="12" width="3" height="28" fill="#FFFFFF" opacity="0.2" rx="1"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
33
stock/props/soda-lemonlime.svg
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="greencan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#006400"/>
|
||||||
|
<stop offset="30%" stop-color="#32CD32"/>
|
||||||
|
<stop offset="70%" stop-color="#32CD32"/>
|
||||||
|
<stop offset="100%" stop-color="#006400"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cantop2" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#A0A0A0"/>
|
||||||
|
<stop offset="50%" stop-color="#D0D0D0"/>
|
||||||
|
<stop offset="100%" stop-color="#A0A0A0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Can body -->
|
||||||
|
<rect x="12" y="10" width="24" height="34" rx="2" fill="url(#greencan)"/>
|
||||||
|
<!-- Can top -->
|
||||||
|
<ellipse cx="24" cy="10" rx="12" ry="4" fill="url(#cantop2)"/>
|
||||||
|
<!-- Pull tab -->
|
||||||
|
<ellipse cx="24" cy="10" rx="4" ry="1.5" fill="#808080"/>
|
||||||
|
<ellipse cx="24" cy="9" rx="2" ry="0.8" fill="#606060"/>
|
||||||
|
<!-- Can bottom rim -->
|
||||||
|
<ellipse cx="24" cy="44" rx="12" ry="3" fill="#A0A0A0"/>
|
||||||
|
<!-- Citrus burst design -->
|
||||||
|
<circle cx="18" cy="26" r="5" fill="#FFFF00" opacity="0.8"/>
|
||||||
|
<circle cx="30" cy="28" r="4" fill="#90EE90" opacity="0.8"/>
|
||||||
|
<!-- Generic lemon-lime text -->
|
||||||
|
<text x="24" y="38" font-family="Arial, sans-serif" font-size="4" font-weight="bold" fill="#FFFFFF" text-anchor="middle">CITRUS</text>
|
||||||
|
<!-- Highlight -->
|
||||||
|
<rect x="14" y="12" width="3" height="28" fill="#FFFFFF" opacity="0.2" rx="1"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
33
stock/props/soda-orange.svg
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="orangecan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#CC5500"/>
|
||||||
|
<stop offset="30%" stop-color="#FF8C00"/>
|
||||||
|
<stop offset="70%" stop-color="#FF8C00"/>
|
||||||
|
<stop offset="100%" stop-color="#CC5500"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cantop3" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#A0A0A0"/>
|
||||||
|
<stop offset="50%" stop-color="#D0D0D0"/>
|
||||||
|
<stop offset="100%" stop-color="#A0A0A0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Can body -->
|
||||||
|
<rect x="12" y="10" width="24" height="34" rx="2" fill="url(#orangecan)"/>
|
||||||
|
<!-- Can top -->
|
||||||
|
<ellipse cx="24" cy="10" rx="12" ry="4" fill="url(#cantop3)"/>
|
||||||
|
<!-- Pull tab -->
|
||||||
|
<ellipse cx="24" cy="10" rx="4" ry="1.5" fill="#808080"/>
|
||||||
|
<ellipse cx="24" cy="9" rx="2" ry="0.8" fill="#606060"/>
|
||||||
|
<!-- Can bottom rim -->
|
||||||
|
<ellipse cx="24" cy="44" rx="12" ry="3" fill="#A0A0A0"/>
|
||||||
|
<!-- Orange slice design -->
|
||||||
|
<circle cx="24" cy="26" r="8" fill="#FFA500"/>
|
||||||
|
<path d="M 24 18 L 24 34 M 16 26 L 32 26 M 18 20 L 30 32 M 30 20 L 18 32" stroke="#FFFFFF" stroke-width="1" opacity="0.6"/>
|
||||||
|
<!-- Generic orange text -->
|
||||||
|
<text x="24" y="40" font-family="Arial, sans-serif" font-size="5" font-weight="bold" fill="#FFFFFF" text-anchor="middle">ORANGE</text>
|
||||||
|
<!-- Highlight -->
|
||||||
|
<rect x="14" y="12" width="3" height="28" fill="#FFFFFF" opacity="0.2" rx="1"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
34
stock/props/soda-rootbeer.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="browncan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#3E1F0D"/>
|
||||||
|
<stop offset="30%" stop-color="#5D3A1A"/>
|
||||||
|
<stop offset="70%" stop-color="#5D3A1A"/>
|
||||||
|
<stop offset="100%" stop-color="#3E1F0D"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cantop5" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#A0A0A0"/>
|
||||||
|
<stop offset="50%" stop-color="#D0D0D0"/>
|
||||||
|
<stop offset="100%" stop-color="#A0A0A0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Can body -->
|
||||||
|
<rect x="12" y="10" width="24" height="34" rx="2" fill="url(#browncan)"/>
|
||||||
|
<!-- Can top -->
|
||||||
|
<ellipse cx="24" cy="10" rx="12" ry="4" fill="url(#cantop5)"/>
|
||||||
|
<!-- Pull tab -->
|
||||||
|
<ellipse cx="24" cy="10" rx="4" ry="1.5" fill="#808080"/>
|
||||||
|
<ellipse cx="24" cy="9" rx="2" ry="0.8" fill="#606060"/>
|
||||||
|
<!-- Can bottom rim -->
|
||||||
|
<ellipse cx="24" cy="44" rx="12" ry="3" fill="#A0A0A0"/>
|
||||||
|
<!-- Vintage style label -->
|
||||||
|
<rect x="14" y="20" width="20" height="16" rx="2" fill="#F5DEB3"/>
|
||||||
|
<rect x="16" y="22" width="16" height="12" rx="1" fill="#8B4513" opacity="0.3"/>
|
||||||
|
<!-- Generic root beer text -->
|
||||||
|
<text x="24" y="27" font-family="Georgia, serif" font-size="4" font-weight="bold" fill="#3E1F0D" text-anchor="middle">ROOT</text>
|
||||||
|
<text x="24" y="32" font-family="Georgia, serif" font-size="4" font-weight="bold" fill="#3E1F0D" text-anchor="middle">BEER</text>
|
||||||
|
<!-- Highlight -->
|
||||||
|
<rect x="14" y="12" width="3" height="28" fill="#FFFFFF" opacity="0.15" rx="1"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
31
stock/props/tea-bag.svg
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="teabag" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#F5F5DC"/>
|
||||||
|
<stop offset="100%" stop-color="#DEB887"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="teabagtag" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFFAF0"/>
|
||||||
|
<stop offset="100%" stop-color="#FAF0E6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Tea bag body -->
|
||||||
|
<path d="M 14 18 L 14 38 Q 14 42 18 42 L 30 42 Q 34 42 34 38 L 34 18 Q 34 14 30 14 L 18 14 Q 14 14 14 18 Z" fill="url(#teabag)"/>
|
||||||
|
<!-- Stitching around edges -->
|
||||||
|
<path d="M 14 18 L 14 38 Q 14 42 18 42 L 30 42 Q 34 42 34 38 L 34 18 Q 34 14 30 14 L 18 14 Q 14 14 14 18 Z" fill="none" stroke="#C4A77D" stroke-width="0.5" stroke-dasharray="2,1"/>
|
||||||
|
<!-- Tea visible through bag -->
|
||||||
|
<ellipse cx="24" cy="28" rx="8" ry="10" fill="#8B4513" opacity="0.3"/>
|
||||||
|
<!-- Folded top -->
|
||||||
|
<path d="M 16 16 L 32 16 L 30 20 L 18 20 Z" fill="#E6D5B8"/>
|
||||||
|
<!-- String -->
|
||||||
|
<path d="M 24 14 Q 24 8 20 6 Q 16 4 12 6" fill="none" stroke="#F5F5DC" stroke-width="1"/>
|
||||||
|
<!-- Tag -->
|
||||||
|
<rect x="6" y="2" width="10" height="8" rx="1" fill="url(#teabagtag)"/>
|
||||||
|
<rect x="6" y="2" width="10" height="8" rx="1" fill="none" stroke="#DDD" stroke-width="0.5"/>
|
||||||
|
<!-- Tag text -->
|
||||||
|
<text x="11" y="7" font-family="Arial, sans-serif" font-size="3" fill="#8B4513" text-anchor="middle">TEA</text>
|
||||||
|
<!-- String attachment to tag -->
|
||||||
|
<circle cx="11" cy="10" r="0.8" fill="#F5F5DC"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
59
stock/props/tea-cup-empty.svg
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="emptyglass" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#D0D8DD"/>
|
||||||
|
<stop offset="30%" stop-color="#F0F5F8"/>
|
||||||
|
<stop offset="70%" stop-color="#F8FCFF"/>
|
||||||
|
<stop offset="100%" stop-color="#D0D8DD"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldrimempty" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#C9A030"/>
|
||||||
|
<stop offset="50%" stop-color="#F0D050"/>
|
||||||
|
<stop offset="100%" stop-color="#C9A030"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Small saucer -->
|
||||||
|
<ellipse cx="24" cy="42" rx="8" ry="2.5" fill="#F0F0F0"/>
|
||||||
|
<ellipse cx="24" cy="42" rx="8" ry="2.5" fill="none" stroke="url(#goldrimempty)" stroke-width="0.4"/>
|
||||||
|
<ellipse cx="24" cy="41.5" rx="6" ry="1.5" fill="#FAFAFA"/>
|
||||||
|
<!-- Turkish tea glass - tulip shape: wide rim, narrow waist, wider base -->
|
||||||
|
<!-- Glass outline -->
|
||||||
|
<path d="M 19 22
|
||||||
|
Q 19 24 21 28
|
||||||
|
Q 22 30 22 32
|
||||||
|
Q 22 34 21 36
|
||||||
|
Q 20 38 20 40
|
||||||
|
L 28 40
|
||||||
|
Q 28 38 27 36
|
||||||
|
Q 26 34 26 32
|
||||||
|
Q 26 30 27 28
|
||||||
|
Q 29 24 29 22
|
||||||
|
Z"
|
||||||
|
fill="url(#emptyglass)" fill-opacity="0.25" stroke="#B0B8BC" stroke-width="0.5"/>
|
||||||
|
<!-- Glass interior - subtle depth -->
|
||||||
|
<path d="M 19.8 23
|
||||||
|
Q 19.8 25 21.4 28.5
|
||||||
|
Q 22.4 30.5 22.4 32
|
||||||
|
Q 22.4 34 21.4 36
|
||||||
|
Q 20.4 38 20.4 39.5
|
||||||
|
L 27.6 39.5
|
||||||
|
Q 27.6 38 26.6 36
|
||||||
|
Q 25.6 34 25.6 32
|
||||||
|
Q 25.6 30.5 26.6 28.5
|
||||||
|
Q 28.2 25 28.2 23
|
||||||
|
Z"
|
||||||
|
fill="#F8FCFF" fill-opacity="0.2"/>
|
||||||
|
<!-- Glass rim - elliptical top -->
|
||||||
|
<ellipse cx="24" cy="22" rx="5" ry="1.8" fill="url(#emptyglass)" fill-opacity="0.4"/>
|
||||||
|
<ellipse cx="24" cy="22" rx="5" ry="1.8" fill="none" stroke="url(#goldrimempty)" stroke-width="0.6"/>
|
||||||
|
<!-- Inner rim visible -->
|
||||||
|
<ellipse cx="24" cy="22.3" rx="4.3" ry="1.4" fill="none" stroke="#E0E8EC" stroke-width="0.3"/>
|
||||||
|
<!-- Glass highlight - thin reflection -->
|
||||||
|
<path d="M 20.5 24 Q 21 28 21.5 34" fill="none" stroke="#FFFFFF" stroke-width="0.8" stroke-opacity="0.6" stroke-linecap="round"/>
|
||||||
|
<!-- Faint tea stain at bottom -->
|
||||||
|
<ellipse cx="24" cy="39" rx="3" ry="0.8" fill="#8B4513" fill-opacity="0.1"/>
|
||||||
|
<!-- Glass base -->
|
||||||
|
<ellipse cx="24" cy="40" rx="4" ry="1.2" fill="url(#emptyglass)" fill-opacity="0.3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
63
stock/props/tea-cup.svg
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="glass" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#D0D8DD"/>
|
||||||
|
<stop offset="30%" stop-color="#F0F5F8"/>
|
||||||
|
<stop offset="70%" stop-color="#F8FCFF"/>
|
||||||
|
<stop offset="100%" stop-color="#D0D8DD"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="turkishtea" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#C44D1A"/>
|
||||||
|
<stop offset="40%" stop-color="#9B2A0F"/>
|
||||||
|
<stop offset="100%" stop-color="#6B1A08"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldrim" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#C9A030"/>
|
||||||
|
<stop offset="50%" stop-color="#F0D050"/>
|
||||||
|
<stop offset="100%" stop-color="#C9A030"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Small saucer -->
|
||||||
|
<ellipse cx="24" cy="42" rx="8" ry="2.5" fill="#F0F0F0"/>
|
||||||
|
<ellipse cx="24" cy="42" rx="8" ry="2.5" fill="none" stroke="url(#goldrim)" stroke-width="0.4"/>
|
||||||
|
<ellipse cx="24" cy="41.5" rx="6" ry="1.5" fill="#FAFAFA"/>
|
||||||
|
<!-- Turkish tea glass - tulip shape: wide rim, narrow waist, wider base -->
|
||||||
|
<!-- Glass outline -->
|
||||||
|
<path d="M 19 22
|
||||||
|
Q 19 24 21 28
|
||||||
|
Q 22 30 22 32
|
||||||
|
Q 22 34 21 36
|
||||||
|
Q 20 38 20 40
|
||||||
|
L 28 40
|
||||||
|
Q 28 38 27 36
|
||||||
|
Q 26 34 26 32
|
||||||
|
Q 26 30 27 28
|
||||||
|
Q 29 24 29 22
|
||||||
|
Z"
|
||||||
|
fill="url(#glass)" fill-opacity="0.3" stroke="#B0B8BC" stroke-width="0.5"/>
|
||||||
|
<!-- Tea fill -->
|
||||||
|
<path d="M 19.5 23
|
||||||
|
Q 19.5 25 21.2 28.5
|
||||||
|
Q 22.2 30.5 22.2 32
|
||||||
|
Q 22.2 34 21.2 36
|
||||||
|
Q 20.2 38 20.2 40
|
||||||
|
L 27.8 40
|
||||||
|
Q 27.8 38 26.8 36
|
||||||
|
Q 25.8 34 25.8 32
|
||||||
|
Q 25.8 30.5 26.8 28.5
|
||||||
|
Q 28.5 25 28.5 23
|
||||||
|
Z"
|
||||||
|
fill="url(#turkishtea)" fill-opacity="0.85"/>
|
||||||
|
<!-- Glass rim - elliptical top -->
|
||||||
|
<ellipse cx="24" cy="22" rx="5" ry="1.8" fill="url(#glass)" fill-opacity="0.5"/>
|
||||||
|
<ellipse cx="24" cy="22" rx="5" ry="1.8" fill="none" stroke="url(#goldrim)" stroke-width="0.6"/>
|
||||||
|
<!-- Tea surface -->
|
||||||
|
<ellipse cx="24" cy="22.5" rx="4.3" ry="1.4" fill="#C44D1A" fill-opacity="0.9"/>
|
||||||
|
<!-- Glass highlight - thin reflection -->
|
||||||
|
<path d="M 20.5 24 Q 21 28 21.5 34" fill="none" stroke="#FFFFFF" stroke-width="0.8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||||
|
<!-- Steam wisps -->
|
||||||
|
<path d="M 22 19 Q 21 16 22 13" fill="none" stroke="#CCCCCC" stroke-width="0.6" stroke-opacity="0.4"/>
|
||||||
|
<path d="M 26 19 Q 27 15 26 12" fill="none" stroke="#CCCCCC" stroke-width="0.6" stroke-opacity="0.4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
34
stock/props/tea-iced.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="icedteaglass" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#D3D3D3"/>
|
||||||
|
<stop offset="50%" stop-color="#F0F0F0"/>
|
||||||
|
<stop offset="100%" stop-color="#D3D3D3"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="icedtea" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#CD853F"/>
|
||||||
|
<stop offset="100%" stop-color="#8B4513"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Tall glass -->
|
||||||
|
<path d="M 12 8 L 15 44 L 33 44 L 36 8 Z" fill="url(#icedteaglass)" opacity="0.5"/>
|
||||||
|
<!-- Iced tea liquid -->
|
||||||
|
<path d="M 13 12 L 16 42 L 32 42 L 35 12 Z" fill="url(#icedtea)" opacity="0.75"/>
|
||||||
|
<!-- Ice cubes -->
|
||||||
|
<rect x="17" y="16" width="6" height="5" rx="1" fill="#E0FFFF" opacity="0.7"/>
|
||||||
|
<rect x="25" y="20" width="5" height="5" rx="1" fill="#E0FFFF" opacity="0.7"/>
|
||||||
|
<rect x="18" y="26" width="5" height="4" rx="1" fill="#E0FFFF" opacity="0.7"/>
|
||||||
|
<!-- Lemon slice on rim -->
|
||||||
|
<ellipse cx="34" cy="10" rx="5" ry="3" fill="#FFD700" transform="rotate(30, 34, 10)"/>
|
||||||
|
<path d="M 32 10 L 36 10 M 34 8 L 34 12" stroke="#FFF8DC" stroke-width="0.5" opacity="0.7"/>
|
||||||
|
<!-- Glass rim -->
|
||||||
|
<ellipse cx="24" cy="8" rx="12" ry="3" fill="url(#icedteaglass)" opacity="0.8"/>
|
||||||
|
<!-- Straw -->
|
||||||
|
<rect x="27" y="2" width="2" height="18" fill="#FF6347"/>
|
||||||
|
<!-- Condensation drops -->
|
||||||
|
<circle cx="14" cy="22" r="1" fill="#87CEEB" opacity="0.6"/>
|
||||||
|
<circle cx="34" cy="30" r="1" fill="#87CEEB" opacity="0.6"/>
|
||||||
|
<circle cx="13" cy="32" r="0.8" fill="#87CEEB" opacity="0.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
52
stock/props/tea-pot.svg
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
|
<g transform="scale(2.5)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="metalpot" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#B0B0B0"/>
|
||||||
|
<stop offset="30%" stop-color="#E8E8E8"/>
|
||||||
|
<stop offset="70%" stop-color="#E8E8E8"/>
|
||||||
|
<stop offset="100%" stop-color="#B0B0B0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="darkmetalpot" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#606060"/>
|
||||||
|
<stop offset="50%" stop-color="#909090"/>
|
||||||
|
<stop offset="100%" stop-color="#606060"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="teainpot" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#8B0000"/>
|
||||||
|
<stop offset="100%" stop-color="#5C1A0B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Bottom pot (larger, for hot water) -->
|
||||||
|
<ellipse cx="24" cy="42" rx="12" ry="4" fill="url(#darkmetalpot)"/>
|
||||||
|
<path d="M 12 30 L 12 40 Q 12 44 16 44 L 32 44 Q 36 44 36 40 L 36 30 Z" fill="url(#metalpot)"/>
|
||||||
|
<ellipse cx="24" cy="30" rx="12" ry="4" fill="url(#metalpot)"/>
|
||||||
|
<!-- Bottom pot handle -->
|
||||||
|
<path d="M 36 34 Q 42 34 42 38 Q 42 42 36 40" fill="none" stroke="url(#darkmetalpot)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<!-- Bottom pot spout -->
|
||||||
|
<path d="M 12 34 Q 6 32 4 28" fill="none" stroke="url(#metalpot)" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M 12 34 Q 6 32 4 28" fill="none" stroke="#D0D0D0" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<!-- Top pot (smaller, for concentrated tea) -->
|
||||||
|
<ellipse cx="24" cy="28" rx="8" ry="3" fill="url(#darkmetalpot)"/>
|
||||||
|
<path d="M 16 16 L 16 26 Q 16 30 20 30 L 28 30 Q 32 30 32 26 L 32 16 Z" fill="url(#metalpot)"/>
|
||||||
|
<ellipse cx="24" cy="16" rx="8" ry="3" fill="url(#metalpot)"/>
|
||||||
|
<!-- Tea visible through top pot -->
|
||||||
|
<ellipse cx="24" cy="22" rx="6" ry="8" fill="url(#teainpot)" opacity="0.3"/>
|
||||||
|
<!-- Top pot lid -->
|
||||||
|
<ellipse cx="24" cy="14" rx="6" ry="2.5" fill="url(#metalpot)"/>
|
||||||
|
<ellipse cx="24" cy="13" rx="4" ry="1.5" fill="#D0D0D0"/>
|
||||||
|
<!-- Lid knob -->
|
||||||
|
<ellipse cx="24" cy="10" rx="2" ry="1.5" fill="url(#darkmetalpot)"/>
|
||||||
|
<ellipse cx="24" cy="9.5" rx="1.5" ry="1" fill="#A0A0A0"/>
|
||||||
|
<!-- Top pot handle -->
|
||||||
|
<path d="M 32 20 Q 36 20 36 24" fill="none" stroke="url(#darkmetalpot)" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- Top pot spout -->
|
||||||
|
<path d="M 16 20 Q 12 18 10 14" fill="none" stroke="url(#metalpot)" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- Steam from top -->
|
||||||
|
<path d="M 22 6 Q 20 2 22 -1" fill="none" stroke="#CCC" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
<path d="M 26 6 Q 28 1 26 -2" fill="none" stroke="#CCC" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
<!-- Metal highlights -->
|
||||||
|
<path d="M 14 32 L 14 38" stroke="#FFF" stroke-width="1" opacity="0.3"/>
|
||||||
|
<path d="M 18 18 L 18 24" stroke="#FFF" stroke-width="1" opacity="0.3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
145
stock/props/upload-stockprops.sh
Executable file
|
|
@ -0,0 +1,145 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Upload all stock props to the server.
|
||||||
|
#
|
||||||
|
# Usage: ./stock/props/upload-stockprops.sh [--force|-f] [HOST]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --force, -f Update existing props instead of failing with 409 Conflict
|
||||||
|
#
|
||||||
|
# HOST defaults to http://localhost:3001 (owner admin port)
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. Run the dev server: ./run-dev.sh -f
|
||||||
|
# 2. Wait for it to finish building: ./run-dev.sh -s
|
||||||
|
#
|
||||||
|
# The owner admin server (port 3001) uses the chattyness_owner DB role
|
||||||
|
# which bypasses RLS, so no authentication is required.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
FORCE=""
|
||||||
|
HOST="http://localhost:3001"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force|-f)
|
||||||
|
FORCE="?force=true"
|
||||||
|
;;
|
||||||
|
http://*)
|
||||||
|
HOST="$arg"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# Script directory is the stock/props directory
|
||||||
|
PROPS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "Uploading stock props to $HOST/api/admin/props"
|
||||||
|
echo "Source directory: $PROPS_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
echo "Checking server health..."
|
||||||
|
health_response=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/api/admin/health" 2>/dev/null || echo "000")
|
||||||
|
if [ "$health_response" != "200" ]; then
|
||||||
|
echo "ERROR: Server is not responding at $HOST (HTTP $health_response)"
|
||||||
|
echo ""
|
||||||
|
echo "Make sure the server is running:"
|
||||||
|
echo " ./run-dev.sh -f"
|
||||||
|
echo " ./run-dev.sh -s # Check status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Server is healthy!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to create display name from filename
|
||||||
|
# e.g., "hookah-traditional.svg" -> "Hookah Traditional"
|
||||||
|
make_display_name() {
|
||||||
|
local filename="$1"
|
||||||
|
local name_without_ext="${filename%.svg}"
|
||||||
|
# Replace hyphens with spaces and capitalize each word
|
||||||
|
echo "$name_without_ext" | sed 's/-/ /g' | sed 's/\b\(.\)/\u\1/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to extract category from filename
|
||||||
|
# e.g., "hookah-traditional.svg" -> "hookah"
|
||||||
|
get_category() {
|
||||||
|
local filename="$1"
|
||||||
|
local name_without_ext="${filename%.svg}"
|
||||||
|
echo "${name_without_ext%%-*}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to determine tags based on category
|
||||||
|
get_tags() {
|
||||||
|
local category="$1"
|
||||||
|
case "$category" in
|
||||||
|
hookah)
|
||||||
|
echo '["hookah", "smoking", "droppable"]'
|
||||||
|
;;
|
||||||
|
coffee)
|
||||||
|
echo '["coffee", "beverage", "droppable"]'
|
||||||
|
;;
|
||||||
|
soda)
|
||||||
|
echo '["soda", "beverage", "droppable"]'
|
||||||
|
;;
|
||||||
|
tea)
|
||||||
|
echo '["tea", "beverage", "droppable"]'
|
||||||
|
;;
|
||||||
|
misc)
|
||||||
|
echo '["misc", "droppable"]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo '["prop", "droppable"]'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Track success/failure counts
|
||||||
|
success_count=0
|
||||||
|
fail_count=0
|
||||||
|
|
||||||
|
# Upload each SVG file
|
||||||
|
for file in "$PROPS_DIR"/*.svg; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename=$(basename "$file")
|
||||||
|
display_name=$(make_display_name "$filename")
|
||||||
|
category=$(get_category "$filename")
|
||||||
|
tags=$(get_tags "$category")
|
||||||
|
|
||||||
|
echo "Uploading: $filename -> $display_name (category: $category)"
|
||||||
|
|
||||||
|
# Create metadata JSON - props are droppable loose items
|
||||||
|
metadata=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"name": "$display_name",
|
||||||
|
"tags": $tags,
|
||||||
|
"droppable": true
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload via curl
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST "$HOST/api/admin/props$FORCE" \
|
||||||
|
-F "metadata=$metadata" \
|
||||||
|
-F "file=@$file")
|
||||||
|
|
||||||
|
# Extract HTTP status code (last line)
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||||
|
echo " Success: $body"
|
||||||
|
((++success_count))
|
||||||
|
else
|
||||||
|
echo " Failed (HTTP $http_code): $body"
|
||||||
|
((++fail_count))
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Upload complete: $success_count succeeded, $fail_count failed"
|
||||||
28
stock/run.py
Executable file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simple HTTP server for the stock avatar compositor."""
|
||||||
|
|
||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PORT = 8080
|
||||||
|
DIRECTORY = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=str(DIRECTORY), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with socketserver.TCPServer(("", PORT), Handler) as httpd:
|
||||||
|
url = f"http://localhost:{PORT}"
|
||||||
|
print(f"Serving at {url}")
|
||||||
|
print("Press Ctrl+C to stop")
|
||||||
|
webbrowser.open(url)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||