libs/newsletters-data-client/src/lib/newsletter-storage/s3-newsletter-storage.ts (356 lines of code) (raw):
import type { S3Client } from '@aws-sdk/client-s3';
import type { DraftNewsletterDataWithMeta } from '../schemas/draft-newsletter-data-type';
import { makeBlankMeta } from '../schemas/meta-data-type';
import type {
NewsletterData,
NewsletterDataWithMeta,
NewsletterDataWithoutMeta,
} from '../schemas/newsletter-data-type';
import { isNewsletterDataWithMeta } from '../schemas/newsletter-data-type';
import type {
SuccessfulStorageResponse,
UnsuccessfulStorageResponse,
} from '../storage-response-types';
import { StorageRequestFailureReason } from '../storage-response-types';
import type { UserProfile } from '../user-profile';
import { NewsletterStorage } from './NewsletterStorage';
import { objectToNewsletter } from './objectToNewsletter';
import {
fetchObject,
getListOfObjectsKeys,
getNextId,
objectExists,
putObject,
} from './s3-functions';
export class S3NewsletterStorage implements NewsletterStorage {
readonly s3Client: S3Client;
readonly bucketName: string;
readonly OBJECT_PREFIX = 'launched-newsletters/';
constructor(bucketName: string, s3Client: S3Client) {
this.bucketName = bucketName;
this.s3Client = s3Client;
}
async create(
draft: DraftNewsletterDataWithMeta,
user: UserProfile,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithoutMeta>
| UnsuccessfulStorageResponse
> {
const draftReady = isNewsletterDataWithMeta(draft);
if (!draftReady) {
const error: UnsuccessfulStorageResponse = {
ok: false,
message: `draft was not ready to be live`,
reason: StorageRequestFailureReason.InvalidDataInput,
};
return Promise.resolve(error);
}
if (!draft.identityName) {
return {
ok: false,
message: `identityName is undefined`,
reason: StorageRequestFailureReason.InvalidDataInput,
};
}
const nextId = await getNextId(this);
const newIdentifier = `${draft.identityName}:${nextId}.json`;
try {
const newsletterWithSameKeyExists = await this.objectExists(
newIdentifier,
);
if (newsletterWithSameKeyExists) {
return {
ok: false,
message: `Newsletter with name ${newIdentifier} already exists`,
reason: StorageRequestFailureReason.DataInStoreNotValid,
};
}
} catch (err) {
return {
ok: false,
message: `failed to check if newsletter with name ${newIdentifier} exists`,
reason: StorageRequestFailureReason.S3Failure,
};
}
const newNewsletter: NewsletterDataWithMeta = {
...draft,
listId: nextId,
meta: this.updateMetaForLaunch(draft.meta, user),
};
try {
await this.putObject(newNewsletter, newIdentifier);
} catch (err) {
return {
ok: false,
message: `failed create newsletter ${draft.identityName}.`,
reason: StorageRequestFailureReason.S3Failure,
};
}
return {
ok: true,
data: this.stripMeta(newNewsletter),
};
}
delete(
listId: number,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithoutMeta>
| UnsuccessfulStorageResponse
> {
// todo - implement this. We don't want to delete published newsletters - we will probably move them to a deleted folder
// this function is not exposed in the API layer; not required for MVP. Deletion will be an engineering task where required.
return Promise.resolve({
ok: false,
message: 'not implemented',
reason: undefined,
});
}
async list(): Promise<
| SuccessfulStorageResponse<NewsletterDataWithoutMeta[]>
| UnsuccessfulStorageResponse
> {
try {
const listOfObjectsKeys = await this.getListOfObjectsKeys();
const data: NewsletterData[] = [];
await Promise.all(
listOfObjectsKeys.map(async (key) => {
const s3Response = await this.fetchObject(key);
const responseAsNewsletter = await objectToNewsletter(s3Response);
if (responseAsNewsletter) {
data.push(responseAsNewsletter);
}
}),
);
const listWithoutMeta = data.map(this.stripMeta);
return {
ok: true,
data: listWithoutMeta,
};
} catch (error) {
console.error(error);
return {
ok: false,
message: `failed to list newsletters`,
reason: StorageRequestFailureReason.S3Failure,
};
}
}
async read(
listId: number,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithoutMeta>
| UnsuccessfulStorageResponse
> {
const newsletter = await this.fetchNewsletter(listId);
if (!newsletter) {
return {
ok: false,
message: `failed to read newsletter with id ${listId}`,
};
}
return {
ok: true,
data: this.stripMeta(newsletter),
};
}
async readWithMeta(
listId: number,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithMeta>
| UnsuccessfulStorageResponse
> {
const newsletter = await this.fetchNewsletter(listId);
if (!newsletter) {
return {
ok: false,
message: `failed to read newsletter with id ${listId}`,
};
}
if (!isNewsletterDataWithMeta(newsletter)) {
return {
ok: false,
message: `newsletter with id ${listId} was missing meta data`,
};
}
return {
ok: true,
data: newsletter,
};
}
async readByName(
identityName: string,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithoutMeta>
| UnsuccessfulStorageResponse
> {
const newsletter = await this.fetchNewsletterByName(identityName);
if (!newsletter) {
return {
ok: false,
message: `failed to read newsletter with name '${identityName}'`,
};
}
return {
ok: true,
data: this.stripMeta(newsletter),
};
}
async readByNameWithMeta(
identityName: string,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithMeta>
| UnsuccessfulStorageResponse
> {
const newsletter = await this.fetchNewsletterByName(identityName);
if (!newsletter) {
return {
ok: false,
message: `failed to read newsletter with name '${identityName}'`,
};
}
if (!isNewsletterDataWithMeta(newsletter)) {
return {
ok: false,
message: `newsletter with name '${identityName}' was missing meta data`,
};
}
return {
ok: true,
data: newsletter,
};
}
async update(
listId: number,
modifications: Partial<NewsletterData>,
user: UserProfile,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithoutMeta>
| UnsuccessfulStorageResponse
> {
const modificationError = this.getModificationError(modifications);
if (modificationError) return modificationError;
const newsletterToUpdate = await this.fetchNewsletter(listId);
if (!newsletterToUpdate) {
return {
ok: false,
message: `failed to read newsletter with id ${listId}`,
};
}
const updatedNewsletter: NewsletterDataWithMeta = {
...newsletterToUpdate,
...modifications,
meta: this.updateMeta(newsletterToUpdate.meta ?? makeBlankMeta(), user),
};
const identifier = `${updatedNewsletter.identityName}:${updatedNewsletter.listId}.json`;
try {
await this.putObject(updatedNewsletter, identifier);
return {
ok: true,
data: this.stripMeta(updatedNewsletter),
};
} catch (err) {
return {
ok: false,
message: `failed to update newsletter with id ${listId}`,
reason: StorageRequestFailureReason.S3Failure,
};
}
}
async replace(
listId: number,
newsletter: NewsletterData,
user: UserProfile,
): Promise<
| SuccessfulStorageResponse<NewsletterDataWithoutMeta>
| UnsuccessfulStorageResponse
> {
const newsletterToUpdate = await this.fetchNewsletter(listId);
if (!newsletterToUpdate) {
return {
ok: false,
message: `failed to read newsletter with id ${listId}`,
};
}
if (
newsletter.identityName !== newsletterToUpdate.identityName ||
newsletter.listId !== newsletterToUpdate.listId
) {
console.error(
`newsletter identityName or listId mismatch for newsletter with id ${listId}`,
);
throw new Error(
`newsletter identityName or listId mismatch for newsletter with id ${listId}`,
);
}
const { identityName } = newsletterToUpdate;
const updatedNewsletter: NewsletterDataWithMeta = {
...newsletter,
identityName,
listId,
meta: this.updateMeta(newsletterToUpdate.meta ?? makeBlankMeta(), user),
};
const identifier = `${identityName}:${listId}.json`;
try {
await this.putObject(updatedNewsletter, identifier);
return {
ok: true,
data: this.stripMeta(updatedNewsletter),
};
} catch (err) {
return {
ok: false,
message: `failed to update newsletter with id ${listId}`,
reason: StorageRequestFailureReason.S3Failure,
};
}
}
private async fetchNewsletter(
listId: number,
): Promise<NewsletterDataWithMeta | NewsletterDataWithoutMeta | undefined> {
const listOfObjectsKeys = await this.getListOfObjectsKeys();
const matchingKey = listOfObjectsKeys.find((key) => {
const keyParts = key.split(':').pop();
const id = keyParts?.split('.')[0];
return id === listId.toString();
});
if (!matchingKey) {
return undefined;
}
const s3Object = await this.fetchObject(matchingKey);
const responseAsNewsletter: NewsletterData | undefined =
await objectToNewsletter(s3Object);
return responseAsNewsletter as
| NewsletterDataWithMeta
| NewsletterDataWithoutMeta
| undefined;
}
private async fetchNewsletterByName(
identityName: string,
): Promise<NewsletterDataWithMeta | NewsletterDataWithoutMeta | undefined> {
const listOfObjectsKeys = await this.getListOfObjectsKeys();
const matchingKey = listOfObjectsKeys.find((key) => {
const keyParts = key.split('/').pop();
const name = keyParts?.split(':')[0];
return name === identityName;
});
if (!matchingKey) {
return undefined;
}
const s3Object = await this.fetchObject(matchingKey);
const responseAsNewsletter: NewsletterData | undefined =
await objectToNewsletter(s3Object);
return responseAsNewsletter as
| NewsletterDataWithMeta
| NewsletterDataWithoutMeta
| undefined;
}
private fetchObject = fetchObject(this);
private putObject = putObject(this);
private objectExists = objectExists(this);
private getListOfObjectsKeys = getListOfObjectsKeys(this);
getModificationError = NewsletterStorage.prototype.getModificationError;
buildNoItemError = NewsletterStorage.prototype.buildNoItemError;
stripMeta = NewsletterStorage.prototype.stripMeta;
createNewMeta = NewsletterStorage.prototype.createNewMeta;
updateMeta = NewsletterStorage.prototype.updateMeta;
updateMetaForLaunch = NewsletterStorage.prototype.updateMetaForLaunch;
}