Skip to content

Commit

Permalink
feat: delete items (#23)
Browse files Browse the repository at this point in the history
* feat(web): list with section title

* feat(web): get items

* feat(web): soft delete items

* feat(functions): batch delete items
  • Loading branch information
KazuyaHara authored Nov 7, 2022
1 parent df21b0f commit 4f3a590
Show file tree
Hide file tree
Showing 15 changed files with 275 additions and 15 deletions.
14 changes: 14 additions & 0 deletions firestore.indexes.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "items",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "deletedAt",
"order": "ASCENDING"
},
{
"fieldPath": "date",
"order": "DESCENDING"
}
]
}
],
"fieldOverrides": []
Expand Down
2 changes: 1 addition & 1 deletion firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ service cloud.firestore {
}

match /items/{itemId} {
allow list, create: if authenticated();
allow read, create, update: if authenticated();
}

match /gears/{gearId} {
Expand Down
1 change: 1 addition & 0 deletions functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"start": "npm run shell"
},
"dependencies": {
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7",
"exif-reader": "^1.0.3",
"firebase-admin": "^10.0.2",
Expand Down
56 changes: 56 additions & 0 deletions functions/src/firestore/item/batchDeleteItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { firestore, storage } from 'firebase-admin';
import { logger, region } from 'firebase-functions';

import { subDays } from 'date-fns';

export const batchDeleteItems = region('asia-northeast1')
.region('asia-northeast1')
.pubsub.schedule('0 0 * * *')
.timeZone('Asia/Tokyo')
.onRun(async () => {
const deadlineDate = subDays(new Date(), 30);

// delete firestore data
const db = firestore();
const batchArray = [] as Array<firestore.WriteBatch>;
batchArray.push(db.batch());
let operationCounter = 0;
let batchIndex = 0;

const deletableItems = await db
.collection('items')
.where('deletedAt', '!=', null)
.where('deletedAt', '<', deadlineDate)
.get()
.catch((error) => {
logger.error('Error occurred while listing deletable items');
throw new Error(error);
});
if (deletableItems.empty) return logger.log('No items to be deleted');
deletableItems.forEach(({ ref }) => {
batchArray[batchIndex].delete(ref);
operationCounter += 1;
if (operationCounter >= 499) {
batchArray.push(db.batch());
batchIndex += 1;
operationCounter = 0;
}
});
await Promise.all(batchArray.map(async (batch) => batch.commit()))
.then(() => logger.info('Items have been deleted'))
.catch((error) => {
logger.error('Error occurred while deleting items');
throw new Error(error);
});

// delete storage files
const bucket = storage().bucket();
return Promise.all(
deletableItems.docs.map((doc) => bucket.deleteFiles({ prefix: `media/${doc.id}` }))
)
.then(() => logger.info('Media have been deleted'))
.catch((error) => {
logger.error('Error occurred while deleting files');
throw new Error(error);
});
});
1 change: 1 addition & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import * as admin from 'firebase-admin';

admin.initializeApp();

export { batchDeleteItems } from './firestore/item/batchDeleteItems';
export { processUploadedMedia } from './storage/media/onFinalize';
39 changes: 35 additions & 4 deletions web/src/adapters/infrastructure/item/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {
collection,
doc,
DocumentReference,
DocumentSnapshot,
FieldValue,
getDoc,
limit,
onSnapshot,
orderBy,
Expand All @@ -12,28 +15,38 @@ import {
setDoc,
Timestamp,
Unsubscribe,
where,
} from 'firebase/firestore';

import { Item } from '../../../domains/item';
import { Medium } from '../../../domains/medium';
import Firebase, { handleFirestoreError } from '../firebase';

type ItemData = Omit<Item, 'id' | 'date' | 'createdAt' | 'updatedAt'> & {
type ItemData = Omit<Item, 'id' | 'date'> & {
date: Timestamp;
createdAt: Timestamp;
updatedAt: Timestamp;
deletedAt: null;
};

export interface IItemDriver {
create(data: Item): Promise<void>;
get(id: string): Promise<DocumentSnapshot<ItemData>>;
getId(): string;
softDelete(id: string): Promise<void>;
subscribe: (
limitNumber: number,
onNext: (querySnapshot: QuerySnapshot<ItemData>) => void
) => Unsubscribe;
}

type CreateParams = { medium: Medium; createdAt: FieldValue; updatedAt: FieldValue };
type CreateParams = {
medium: Medium;
createdAt: FieldValue;
updatedAt: FieldValue;
deletedAt: null;
};
type SoftDeleteParams = { deletedAt: FieldValue };

export default function itemDriver(): IItemDriver {
const itemsRef = collection(Firebase.instance.firetore, 'items');
Expand All @@ -43,22 +56,40 @@ export default function itemDriver(): IItemDriver {
...data,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
deletedAt: null,
};
return setDoc(doc(itemsRef, id), params).catch((error) => {
throw handleFirestoreError(error);
});
};

const get = async (id: string) =>
getDoc(doc(itemsRef, id) as DocumentReference<ItemData>).catch((error) => {
throw handleFirestoreError(error);
});

const getId = () => doc(itemsRef).id;

const softDelete = async (id: string) => {
const params: SoftDeleteParams = { deletedAt: serverTimestamp() };
return setDoc(doc(itemsRef, id), params, { merge: true }).catch((error) => {
throw handleFirestoreError(error);
});
};

const subscribe = (
limitNumber: number,
onNext: (querySnapshot: QuerySnapshot<ItemData>) => void
) =>
onSnapshot(
query(itemsRef as Query<ItemData>, orderBy('date', 'desc'), limit(limitNumber)),
query(
itemsRef as Query<ItemData>,
where('deletedAt', '==', null),
orderBy('date', 'desc'),
limit(limitNumber)
),
onNext
);

return { create, getId, subscribe };
return { create, get, getId, softDelete, subscribe };
}
19 changes: 18 additions & 1 deletion web/src/adapters/repositories/item/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,25 @@ export default function itemRepository(): IItemRepository {
await itemDriver().create(data);
};

const get = async (id: string): Promise<Item | null> =>
itemDriver()
.get(id)
.then((doc) => {
if (!doc.exists()) return null;
const data = doc.data();
return {
id: doc.id,
...data,
date: data.date.toDate(),
createdAt: data.createdAt.toDate(),
updatedAt: data.updatedAt.toDate(),
};
});

const getId = (): string => itemDriver().getId();

const softDelete = (id: string): Promise<void> => itemDriver().softDelete(id);

const subscribe = (limit: number, onNext: (items: Item[]) => void) =>
itemDriver().subscribe(limit, (querySnapshot) =>
onNext(
Expand All @@ -25,5 +42,5 @@ export default function itemRepository(): IItemRepository {
)
);

return { create, getId, subscribe };
return { create, get, getId, softDelete, subscribe };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';

import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';

type Props = { onClose: () => void; onSubmit: () => void; open: boolean };

export default function ItemDeleteDialog({ onClose, onSubmit, open }: Props) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle variant="h2">このメディアを削除しますか?</DialogTitle>
<DialogContent>
<DialogContentText variant="body1">
この操作は元に戻すことができません。本当によろしいですか?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>キャンセルする</Button>
<Button color="error" onClick={onSubmit}>
削除する
</Button>
</DialogActions>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,48 @@
import React from 'react';

import { Grid, GridProps } from '@mui/material';
import { Box, BoxProps, Grid, Typography } from '@mui/material';
import { format } from 'date-fns';
import { Link } from 'react-router-dom';

import { ItemWithURL } from '../../../../../../domains/item';
import AspectRetioImage from '../../../atoms/aspectRatioImage';

type Props = Pick<GridProps, 'sx'> & { items: ItemWithURL[] };
type Props = Pick<BoxProps, 'sx'> & { items: ItemWithURL[] };

export default function ItemSectionList({ items, sx }: Props) {
const months = items
.map((item) => format(item.date, 'yyyy-MM'))
.filter((elem, index, self) => self.indexOf(elem) === index);
const sections = months.map((month) => {
const list = items.filter((item) => format(item.date, 'yyyy-MM') === month);
const title = format(list[0].date, 'yyyy年M月');
return { list, title };
});

return (
<Grid container spacing={1} sx={sx}>
{items.map((item) => (
<Grid item key={item.id} xs={4} sm={3} md={2}>
<AspectRetioImage borderRadius={1} src={item.url} />
</Grid>
<Box sx={sx}>
{sections.map((section) => (
<Box key={section.title} mb={3}>
<Typography gutterBottom variant="h2">
{section.title}
</Typography>
<Grid container spacing={1}>
{section.list.map((item) => (
<Grid
component={Link}
item
key={item.id}
xs={4}
sm={3}
md={2}
to={`/media/${item.id}`}
>
<AspectRetioImage borderRadius={1} src={item.url} />
</Grid>
))}
</Grid>
</Box>
))}
</Grid>
</Box>
);
}
67 changes: 67 additions & 0 deletions web/src/adapters/userInterface/components/pages/item/get/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { useEffect, useState } from 'react';

import { Box, Button } from '@mui/material';
import { Navigate, useNavigate, useParams } from 'react-router-dom';

import useItemUseCase from '../../../../../../application/useCases/item';
import { ItemWithURL } from '../../../../../../domains/item';
import itemRepository from '../../../../../repositories/item';
import mediumRepository from '../../../../../repositories/medium';
import { useAlertStore } from '../../../../../stores/alert';
import Dialog from '../../../molecules/dialog/item/delete';
import Loading from '../../loading';

export default function ItemGet() {
const { get: getItem, softDelete: softDeleteItem } = useItemUseCase(
itemRepository(),
mediumRepository()
);
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [item, setItem] = useState<ItemWithURL | null>();
const [loading, setLoading] = useState(false);
const [openDialog, setOpenDialog] = useState(false);

useEffect(() => {
if (id) {
getItem(id).then(setItem);
} else {
navigate('/media');
}
}, [id]);

const onDelete = async () => {
if (!id) return navigate('/media');

setLoading(true);
return softDeleteItem(id)
.then(() => {
useAlertStore.setState({
message: 'メディアを削除しました',
open: true,
severity: 'success',
});
navigate('/media');
})
.catch(({ message }: Error) => {
setLoading(false);
useAlertStore.setState({ message, open: true, severity: 'error' });
});
};

const toggleDialog = () => setOpenDialog(!openDialog);

if (typeof item === 'undefined') return <Loading />;
if (!item) return <Navigate to="/media" />;
return (
<Box pb={3}>
<Box component="img" src={item.url} />
<Box display="flex" justifyContent="flex-end" mt={3}>
<Button color="error" disabled={loading} onClick={toggleDialog} size="small">
このメディアを削除する
</Button>
</Box>
<Dialog onClose={toggleDialog} onSubmit={onDelete} open={openDialog} />
</Box>
);
}
2 changes: 2 additions & 0 deletions web/src/adapters/userInterface/routes/authenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import GearAdd from '../components/pages/gear/add';
import GearEdit from '../components/pages/gear/edit';
import GearList from '../components/pages/gear/list';
import ItemAdd from '../components/pages/item/add';
import ItemGet from '../components/pages/item/get';
import ItemList from '../components/pages/item/list';

export default function Authenticated() {
Expand All @@ -22,6 +23,7 @@ export default function Authenticated() {
{ path: '/gears/:id', element: <GearEdit /> },
{ path: '/media', element: <ItemList /> },
{ path: '/media/add', element: <ItemAdd /> },
{ path: '/media/:id', element: <ItemGet /> },
{ path: '*', element: <Navigate to="/media" replace /> },
]) || <ItemList />
);
Expand Down
Loading

0 comments on commit 4f3a590

Please sign in to comment.