Skip to content

Commit

Permalink
Merge pull request #255 from fmasa/character-duplication
Browse files Browse the repository at this point in the history
Character duplication using cloud functions
  • Loading branch information
fmasa authored Feb 25, 2024
2 parents 70bc63f + 3a2bf7d commit 3c284de
Show file tree
Hide file tree
Showing 12 changed files with 226 additions and 162 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -372,13 +372,6 @@ val appModule = DI.Module("Common") {
partyId,
instance(),
instance(),
instance(),
instance(),
instance(),
instance(),
instance(),
instance(),
instance(),
)
}
bindFactory { partyId: PartyId ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ package cz.frantisekmasa.wfrp_master.common.core.domain.character

import androidx.compose.runtime.Immutable
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import cz.frantisekmasa.wfrp_master.common.core.auth.UserId
import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength
import cz.frantisekmasa.wfrp_master.common.core.domain.Ambitions
import cz.frantisekmasa.wfrp_master.common.core.domain.Money
import cz.frantisekmasa.wfrp_master.common.core.domain.Size
import cz.frantisekmasa.wfrp_master.common.core.domain.Stats
import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.Encumbrance
import cz.frantisekmasa.wfrp_master.common.core.utils.duplicateName
import cz.frantisekmasa.wfrp_master.common.encounters.domain.Wounds
import dev.icerock.moko.parcelize.Parcelable
import dev.icerock.moko.parcelize.Parcelize
Expand Down Expand Up @@ -239,11 +237,6 @@ data class Character(

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

fun duplicate() = copy(
id = uuid4().toString(),
name = duplicateName(name),
)

fun archive() = copy(
isArchived = true,
userId = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.EmptyUI
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ItemIcon
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.SearchableList
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel
import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.LocalPersistentSnackbarHolder
import dev.icerock.moko.resources.compose.stringResource
import io.github.aakira.napier.Napier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

Expand Down Expand Up @@ -116,6 +118,9 @@ class NpcsScreen(
}
) { npc ->
Column {
val unknownErrorMessage = stringResource(Str.messages_error_unknown)
val snackbarHolder = LocalPersistentSnackbarHolder.current

WithContextMenu(
onClick = {
navigation.navigate(CharacterDetailScreen(CharacterId(partyId, npc.id)))
Expand All @@ -126,6 +131,9 @@ class NpcsScreen(
processing = true
try {
screenModel.duplicate(npc)
} catch (e: Exception) {
Napier.e("Failed to duplicate NPC", e)
snackbarHolder.showSnackbar(unknownErrorMessage)
} finally {
processing = false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,17 @@ package cz.frantisekmasa.wfrp_master.common.npcs

import cafe.adriel.voyager.core.model.ScreenModel
import cz.frantisekmasa.wfrp_master.common.core.domain.character.Character
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterItem
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterItemRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterType
import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId
import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId
import cz.frantisekmasa.wfrp_master.common.core.domain.religion.BlessingRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.religion.MiracleRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.skills.SkillRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.spells.SpellRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.talents.TalentRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.traits.TraitRepository
import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.InventoryItemRepository
import cz.frantisekmasa.wfrp_master.common.firebase.firestore.Firestore
import cz.frantisekmasa.wfrp_master.common.firebase.firestore.Transaction
import cz.frantisekmasa.wfrp_master.common.core.utils.duplicateName
import cz.frantisekmasa.wfrp_master.common.firebase.functions.CloudFunctions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first

class NpcsScreenModel(
private val partyId: PartyId,
private val functions: CloudFunctions,
private val characters: CharacterRepository,
private val skills: SkillRepository,
private val talents: TalentRepository,
private val traits: TraitRepository,
private val spells: SpellRepository,
private val blessings: BlessingRepository,
private val miracles: MiracleRepository,
private val trappings: InventoryItemRepository,
private val firestore: Firestore,
) : ScreenModel {

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

suspend fun duplicate(npc: Character) {
firestore.runTransaction { transaction ->
val newNpc = npc.duplicate()
val existingCharacterId = CharacterId(partyId, npc.id)
val newCharacterId = CharacterId(partyId, newNpc.id)

copyItems(transaction, skills, existingCharacterId, newCharacterId)
copyItems(transaction, talents, existingCharacterId, newCharacterId)
copyItems(transaction, traits, existingCharacterId, newCharacterId)
copyItems(transaction, spells, existingCharacterId, newCharacterId)
copyItems(transaction, blessings, existingCharacterId, newCharacterId)
copyItems(transaction, miracles, existingCharacterId, newCharacterId)
copyItems(transaction, trappings, existingCharacterId, newCharacterId)

characters.save(transaction, partyId, newNpc)
}
}

private suspend fun <T : CharacterItem<T, *>> copyItems(
transaction: Transaction,
repository: CharacterItemRepository<T>,
existingCharacterId: CharacterId,
newCharacterId: CharacterId,
) {
repository.findAllForCharacter(existingCharacterId)
.first()
.forEach { item ->
repository.save(transaction, newCharacterId, item)
}
functions.getHttpsCallable("duplicateCharacter")
.call(
mapOf(
"partyId" to partyId.toString(),
"characterId" to npc.id,
"newName" to duplicateName(npc.name)
)
)
}
}
18 changes: 15 additions & 3 deletions functions/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
"@google-cloud/storage": "^5.14.4",
"@types/sharp": "^0.29.2",
"@types/tmp": "^0.2.1",
"@types/uuid": "^9.0.8",
"fast-crc32c": "^2.0.0",
"firebase-admin": "^9.12.0",
"firebase-functions": "^3.15.7",
"fp-ts": "^2.11.4",
"io-ts": "^2.2.16",
"sharp": "^0.29.1",
"tmp-promise": "^3.0.2"
"tmp-promise": "^3.0.2",
"uuid": "^9.0.1"
},
"devDependencies": {
"firebase-functions-test": "^0.2.0",
Expand Down
24 changes: 24 additions & 0 deletions functions/src/avatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {Bucket, CopyResponse, UploadResponse} from "@google-cloud/storage";

export const generateAvatarUrl = async (response: UploadResponse|CopyResponse, bucket: Bucket): Promise<string> => {
if ("FIREBASE_STORAGE_EMULATOR_HOST" in process.env) {
const [metadata] = await response[0].getMetadata();

return metadata.mediaLink;
}

return "https://firebasestorage.googleapis.com/v0/b/"
+ bucket.name
+ "/o/"
+ encodeURIComponent(response[0].name)
+ "?alt=media"
+ "&v="
+ (+new Date());
}

export const getAvatarPath = (partyId: string, characterId: string): string => `images/parties/${partyId}/characters/${characterId}.webp`;

export const METADATA = {
contentType: "image/webp",
cacheControl: `max-age=${365 * 24 * 60 * 60}`,
};
61 changes: 61 additions & 0 deletions functions/src/characterChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as t from "io-ts";
import {isLeft} from "fp-ts/Either";
import * as functions from "firebase-functions";
import {hasAccessToCharacter} from "./acl";
import {firestore} from "firebase-admin";

const RequiredFields = t.type({
partyId: t.string,
characterId: t.string,
})

type RequiredProps = (typeof RequiredFields)['props'];
type RequestBody<T> = T extends t.TypeC<any> ? (T['props'] extends RequiredProps ? T : never) : never;

export const characterChange = <T>(
requestBodyCodec: RequestBody<T>,
handler: (body: t.TypeOf<RequestBody<T>>, character: firestore.DocumentReference) => Promise<any>,
) => {
return functions.https.onCall(async (data, context) => {
const body = requestBodyCodec.decode(data);

if (isLeft(body)) {
return {
status: "error",
error: 400,
message: "Invalid request body",
};
}

const userId = context.auth?.uid;
const {characterId, partyId} = body.right;

if (userId === undefined) {
return {
status: "error",
error: 401,
message: "User is not authorized",
};
}

if (!await hasAccessToCharacter(userId, partyId, characterId)) {
return {
status: "error",
error: 403,
message: "User does not have access to given character",
};
}

const character = firestore().doc(`parties/${partyId}/characters/${characterId}`);

if (!(await character.get()).exists) {
return {
status: "error",
error: 404,
message: "Character does not exist",
}
}

return handler(body.right, character);
});
};
72 changes: 12 additions & 60 deletions functions/src/functions/changeCharacterAvatar.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,22 @@
import * as functions from "firebase-functions";
import {firestore, storage} from 'firebase-admin';
import {isLeft} from "fp-ts/Either";
import {hasAccessToCharacter} from "../acl";
import {storage} from 'firebase-admin';
import {file} from "tmp-promise";
import * as sharp from "sharp";
import * as t from "io-ts";
import {UploadResponse} from "@google-cloud/storage/build/src/bucket";
import {Bucket} from "@google-cloud/storage";
import {characterChange} from "../characterChange";
import {generateAvatarUrl, getAvatarPath, METADATA} from "../avatar";

const imageSize = 500;

const RequestBody = t.interface({
const RequestBody = t.type({
partyId: t.string,
characterId: t.string,
imageData: t.string,
});

export const changeCharacterAvatar = functions.https.onCall(async (data, context) => {
const body = RequestBody.decode(data);

if (isLeft(body)) {
return {
error: 400,
message: "Invalid request body",
};
}

const userId = context.auth?.uid;
const partyId = body.right.partyId;
const characterId = body.right.characterId;
const imageData = body.right.imageData;

if (userId === undefined) {
return {
status: "error",
error: 401,
message: "User is not authorized",
};
}

if (!await hasAccessToCharacter(userId, partyId, characterId)) {
return {
status: "error",
error: 403,
message: "User does not have access to given character",
};
}
export const changeCharacterAvatar = characterChange(RequestBody,async (body, character) => {
const partyId = body.partyId;
const characterId = body.characterId;
const imageData = body.imageData;

const tempFile = await file();

Expand All @@ -58,21 +29,16 @@ export const changeCharacterAvatar = functions.https.onCall(async (data, context
const response = await bucket.upload(
tempFile.path,
{
destination: `images/parties/${partyId}/characters/${characterId}.webp`,
metadata: {
contentType: "image/webp",
cacheControl: `max-age=${365 * 24 * 60 * 60}`,
},
destination: getAvatarPath(partyId, characterId),
metadata: METADATA,
}
);

const url = generateAvatarUrl(response, bucket);
const url = await generateAvatarUrl(response, bucket);

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

await firestore().doc(`parties/${partyId}/characters/${characterId}`)
.update("avatarUrl", url)

await character.update("avatarUrl", url);
await tempFile.cleanup();

return {
Expand Down Expand Up @@ -101,17 +67,3 @@ const cropToRectangle = async (image: sharp.Sharp): Promise<sharp.Sharp> => {
height: size,
})
}

const generateAvatarUrl = (response: UploadResponse, bucket: Bucket): string => {
if ("FIREBASE_STORAGE_EMULATOR_HOST" in process.env) {
return response[1].mediaLink;
}

return "https://firebasestorage.googleapis.com/v0/b/"
+ bucket.name
+ "/o/"
+ encodeURIComponent(response[0].name)
+ "?alt=media"
+ "&v="
+ (+new Date());
}
Loading

0 comments on commit 3c284de

Please sign in to comment.