Skip to content

Commit 3c284de

Browse files
authored
Merge pull request #255 from fmasa/character-duplication
Character duplication using cloud functions
2 parents 70bc63f + 3a2bf7d commit 3c284de

File tree

12 files changed

+226
-162
lines changed

12 files changed

+226
-162
lines changed

common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/DependencyInjection.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -372,13 +372,6 @@ val appModule = DI.Module("Common") {
372372
partyId,
373373
instance(),
374374
instance(),
375-
instance(),
376-
instance(),
377-
instance(),
378-
instance(),
379-
instance(),
380-
instance(),
381-
instance(),
382375
)
383376
}
384377
bindFactory { partyId: PartyId ->

common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/Character.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@ package cz.frantisekmasa.wfrp_master.common.core.domain.character
22

33
import androidx.compose.runtime.Immutable
44
import com.benasher44.uuid.Uuid
5-
import com.benasher44.uuid.uuid4
65
import cz.frantisekmasa.wfrp_master.common.core.auth.UserId
76
import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength
87
import cz.frantisekmasa.wfrp_master.common.core.domain.Ambitions
98
import cz.frantisekmasa.wfrp_master.common.core.domain.Money
109
import cz.frantisekmasa.wfrp_master.common.core.domain.Size
1110
import cz.frantisekmasa.wfrp_master.common.core.domain.Stats
1211
import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.Encumbrance
13-
import cz.frantisekmasa.wfrp_master.common.core.utils.duplicateName
1412
import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds
1513
import dev.icerock.moko.parcelize.Parcelable
1614
import dev.icerock.moko.parcelize.Parcelize
@@ -239,11 +237,6 @@ data class Character(
239237

240238
fun updateConditions(newConditions: CurrentConditions) = copy(conditions = newConditions)
241239

242-
fun duplicate() = copy(
243-
id = uuid4().toString(),
244-
name = duplicateName(name),
245-
)
246-
247240
fun archive() = copy(
248241
isArchived = true,
249242
userId = null,

common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcsScreen.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.EmptyUI
3636
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ItemIcon
3737
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.SearchableList
3838
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel
39+
import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.LocalPersistentSnackbarHolder
3940
import dev.icerock.moko.resources.compose.stringResource
41+
import io.github.aakira.napier.Napier
4042
import kotlinx.coroutines.Dispatchers
4143
import kotlinx.coroutines.launch
4244

@@ -116,6 +118,9 @@ class NpcsScreen(
116118
}
117119
) { npc ->
118120
Column {
121+
val unknownErrorMessage = stringResource(Str.messages_error_unknown)
122+
val snackbarHolder = LocalPersistentSnackbarHolder.current
123+
119124
WithContextMenu(
120125
onClick = {
121126
navigation.navigate(CharacterDetailScreen(CharacterId(partyId, npc.id)))
@@ -126,6 +131,9 @@ class NpcsScreen(
126131
processing = true
127132
try {
128133
screenModel.duplicate(npc)
134+
} catch (e: Exception) {
135+
Napier.e("Failed to duplicate NPC", e)
136+
snackbarHolder.showSnackbar(unknownErrorMessage)
129137
} finally {
130138
processing = false
131139
}

common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/npcs/NpcsScreenModel.kt

Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,17 @@ package cz.frantisekmasa.wfrp_master.common.npcs
22

33
import cafe.adriel.voyager.core.model.ScreenModel
44
import cz.frantisekmasa.wfrp_master.common.core.domain.character.Character
5-
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterItem
6-
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterItemRepository
75
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterRepository
86
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterType
9-
import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId
107
import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId
11-
import cz.frantisekmasa.wfrp_master.common.core.domain.religion.BlessingRepository
12-
import cz.frantisekmasa.wfrp_master.common.core.domain.religion.MiracleRepository
13-
import cz.frantisekmasa.wfrp_master.common.core.domain.skills.SkillRepository
14-
import cz.frantisekmasa.wfrp_master.common.core.domain.spells.SpellRepository
15-
import cz.frantisekmasa.wfrp_master.common.core.domain.talents.TalentRepository
16-
import cz.frantisekmasa.wfrp_master.common.core.domain.traits.TraitRepository
17-
import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.InventoryItemRepository
18-
import cz.frantisekmasa.wfrp_master.common.firebase.firestore.Firestore
19-
import cz.frantisekmasa.wfrp_master.common.firebase.firestore.Transaction
8+
import cz.frantisekmasa.wfrp_master.common.core.utils.duplicateName
9+
import cz.frantisekmasa.wfrp_master.common.firebase.functions.CloudFunctions
2010
import kotlinx.coroutines.flow.Flow
21-
import kotlinx.coroutines.flow.first
2211

2312
class NpcsScreenModel(
2413
private val partyId: PartyId,
14+
private val functions: CloudFunctions,
2515
private val characters: CharacterRepository,
26-
private val skills: SkillRepository,
27-
private val talents: TalentRepository,
28-
private val traits: TraitRepository,
29-
private val spells: SpellRepository,
30-
private val blessings: BlessingRepository,
31-
private val miracles: MiracleRepository,
32-
private val trappings: InventoryItemRepository,
33-
private val firestore: Firestore,
3416
) : ScreenModel {
3517

3618
val npcs: Flow<List<Character>> = characters.inParty(partyId, CharacterType.NPC)
@@ -40,33 +22,13 @@ class NpcsScreenModel(
4022
}
4123

4224
suspend fun duplicate(npc: Character) {
43-
firestore.runTransaction { transaction ->
44-
val newNpc = npc.duplicate()
45-
val existingCharacterId = CharacterId(partyId, npc.id)
46-
val newCharacterId = CharacterId(partyId, newNpc.id)
47-
48-
copyItems(transaction, skills, existingCharacterId, newCharacterId)
49-
copyItems(transaction, talents, existingCharacterId, newCharacterId)
50-
copyItems(transaction, traits, existingCharacterId, newCharacterId)
51-
copyItems(transaction, spells, existingCharacterId, newCharacterId)
52-
copyItems(transaction, blessings, existingCharacterId, newCharacterId)
53-
copyItems(transaction, miracles, existingCharacterId, newCharacterId)
54-
copyItems(transaction, trappings, existingCharacterId, newCharacterId)
55-
56-
characters.save(transaction, partyId, newNpc)
57-
}
58-
}
59-
60-
private suspend fun <T : CharacterItem<T, *>> copyItems(
61-
transaction: Transaction,
62-
repository: CharacterItemRepository<T>,
63-
existingCharacterId: CharacterId,
64-
newCharacterId: CharacterId,
65-
) {
66-
repository.findAllForCharacter(existingCharacterId)
67-
.first()
68-
.forEach { item ->
69-
repository.save(transaction, newCharacterId, item)
70-
}
25+
functions.getHttpsCallable("duplicateCharacter")
26+
.call(
27+
mapOf(
28+
"partyId" to partyId.toString(),
29+
"characterId" to npc.id,
30+
"newName" to duplicateName(npc.name)
31+
)
32+
)
7133
}
7234
}

functions/package-lock.json

Lines changed: 15 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

functions/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
"@google-cloud/storage": "^5.14.4",
1818
"@types/sharp": "^0.29.2",
1919
"@types/tmp": "^0.2.1",
20+
"@types/uuid": "^9.0.8",
2021
"fast-crc32c": "^2.0.0",
2122
"firebase-admin": "^9.12.0",
2223
"firebase-functions": "^3.15.7",
2324
"fp-ts": "^2.11.4",
2425
"io-ts": "^2.2.16",
2526
"sharp": "^0.29.1",
26-
"tmp-promise": "^3.0.2"
27+
"tmp-promise": "^3.0.2",
28+
"uuid": "^9.0.1"
2729
},
2830
"devDependencies": {
2931
"firebase-functions-test": "^0.2.0",

functions/src/avatar.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {Bucket, CopyResponse, UploadResponse} from "@google-cloud/storage";
2+
3+
export const generateAvatarUrl = async (response: UploadResponse|CopyResponse, bucket: Bucket): Promise<string> => {
4+
if ("FIREBASE_STORAGE_EMULATOR_HOST" in process.env) {
5+
const [metadata] = await response[0].getMetadata();
6+
7+
return metadata.mediaLink;
8+
}
9+
10+
return "https://firebasestorage.googleapis.com/v0/b/"
11+
+ bucket.name
12+
+ "/o/"
13+
+ encodeURIComponent(response[0].name)
14+
+ "?alt=media"
15+
+ "&v="
16+
+ (+new Date());
17+
}
18+
19+
export const getAvatarPath = (partyId: string, characterId: string): string => `images/parties/${partyId}/characters/${characterId}.webp`;
20+
21+
export const METADATA = {
22+
contentType: "image/webp",
23+
cacheControl: `max-age=${365 * 24 * 60 * 60}`,
24+
};

functions/src/characterChange.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as t from "io-ts";
2+
import {isLeft} from "fp-ts/Either";
3+
import * as functions from "firebase-functions";
4+
import {hasAccessToCharacter} from "./acl";
5+
import {firestore} from "firebase-admin";
6+
7+
const RequiredFields = t.type({
8+
partyId: t.string,
9+
characterId: t.string,
10+
})
11+
12+
type RequiredProps = (typeof RequiredFields)['props'];
13+
type RequestBody<T> = T extends t.TypeC<any> ? (T['props'] extends RequiredProps ? T : never) : never;
14+
15+
export const characterChange = <T>(
16+
requestBodyCodec: RequestBody<T>,
17+
handler: (body: t.TypeOf<RequestBody<T>>, character: firestore.DocumentReference) => Promise<any>,
18+
) => {
19+
return functions.https.onCall(async (data, context) => {
20+
const body = requestBodyCodec.decode(data);
21+
22+
if (isLeft(body)) {
23+
return {
24+
status: "error",
25+
error: 400,
26+
message: "Invalid request body",
27+
};
28+
}
29+
30+
const userId = context.auth?.uid;
31+
const {characterId, partyId} = body.right;
32+
33+
if (userId === undefined) {
34+
return {
35+
status: "error",
36+
error: 401,
37+
message: "User is not authorized",
38+
};
39+
}
40+
41+
if (!await hasAccessToCharacter(userId, partyId, characterId)) {
42+
return {
43+
status: "error",
44+
error: 403,
45+
message: "User does not have access to given character",
46+
};
47+
}
48+
49+
const character = firestore().doc(`parties/${partyId}/characters/${characterId}`);
50+
51+
if (!(await character.get()).exists) {
52+
return {
53+
status: "error",
54+
error: 404,
55+
message: "Character does not exist",
56+
}
57+
}
58+
59+
return handler(body.right, character);
60+
});
61+
};
Lines changed: 12 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,22 @@
1-
import * as functions from "firebase-functions";
2-
import {firestore, storage} from 'firebase-admin';
3-
import {isLeft} from "fp-ts/Either";
4-
import {hasAccessToCharacter} from "../acl";
1+
import {storage} from 'firebase-admin';
52
import {file} from "tmp-promise";
63
import * as sharp from "sharp";
74
import * as t from "io-ts";
8-
import {UploadResponse} from "@google-cloud/storage/build/src/bucket";
9-
import {Bucket} from "@google-cloud/storage";
5+
import {characterChange} from "../characterChange";
6+
import {generateAvatarUrl, getAvatarPath, METADATA} from "../avatar";
107

118
const imageSize = 500;
129

13-
const RequestBody = t.interface({
10+
const RequestBody = t.type({
1411
partyId: t.string,
1512
characterId: t.string,
1613
imageData: t.string,
1714
});
1815

19-
export const changeCharacterAvatar = functions.https.onCall(async (data, context) => {
20-
const body = RequestBody.decode(data);
21-
22-
if (isLeft(body)) {
23-
return {
24-
error: 400,
25-
message: "Invalid request body",
26-
};
27-
}
28-
29-
const userId = context.auth?.uid;
30-
const partyId = body.right.partyId;
31-
const characterId = body.right.characterId;
32-
const imageData = body.right.imageData;
33-
34-
if (userId === undefined) {
35-
return {
36-
status: "error",
37-
error: 401,
38-
message: "User is not authorized",
39-
};
40-
}
41-
42-
if (!await hasAccessToCharacter(userId, partyId, characterId)) {
43-
return {
44-
status: "error",
45-
error: 403,
46-
message: "User does not have access to given character",
47-
};
48-
}
16+
export const changeCharacterAvatar = characterChange(RequestBody,async (body, character) => {
17+
const partyId = body.partyId;
18+
const characterId = body.characterId;
19+
const imageData = body.imageData;
4920

5021
const tempFile = await file();
5122

@@ -58,21 +29,16 @@ export const changeCharacterAvatar = functions.https.onCall(async (data, context
5829
const response = await bucket.upload(
5930
tempFile.path,
6031
{
61-
destination: `images/parties/${partyId}/characters/${characterId}.webp`,
62-
metadata: {
63-
contentType: "image/webp",
64-
cacheControl: `max-age=${365 * 24 * 60 * 60}`,
65-
},
32+
destination: getAvatarPath(partyId, characterId),
33+
metadata: METADATA,
6634
}
6735
);
6836

69-
const url = generateAvatarUrl(response, bucket);
37+
const url = await generateAvatarUrl(response, bucket);
7038

7139
console.debug(`File url: ${url}`);
7240

73-
await firestore().doc(`parties/${partyId}/characters/${characterId}`)
74-
.update("avatarUrl", url)
75-
41+
await character.update("avatarUrl", url);
7642
await tempFile.cleanup();
7743

7844
return {
@@ -101,17 +67,3 @@ const cropToRectangle = async (image: sharp.Sharp): Promise<sharp.Sharp> => {
10167
height: size,
10268
})
10369
}
104-
105-
const generateAvatarUrl = (response: UploadResponse, bucket: Bucket): string => {
106-
if ("FIREBASE_STORAGE_EMULATOR_HOST" in process.env) {
107-
return response[1].mediaLink;
108-
}
109-
110-
return "https://firebasestorage.googleapis.com/v0/b/"
111-
+ bucket.name
112-
+ "/o/"
113-
+ encodeURIComponent(response[0].name)
114-
+ "?alt=media"
115-
+ "&v="
116-
+ (+new Date());
117-
}

0 commit comments

Comments
 (0)