Base model refactor (#810)
* Big upgrades * WIP: Stash * Stash, 30 flow errors left * Downgrade mobx * WIP * When I understand the difference between class and instance methods * 💚 * Fixes: File import Model saving edge cases pinning and starring docs Collection editing Upgrade mobx devtools * Notification settings saving works * Disabled settings * Document mailer * Working notifications * Colletion created notification Ensure not notified for own actions * Tidy up * Document updated event only for document creation Add indexes Notification setting on user creation * Commentary * Fixed: Notification setting on signup * Fix document move / duplicate stale data Add BaseModel.refresh method * Fixes: Title in sidebar not updated after editing document * 💚 * Improve / restore error handling Better handle offline errors * 👕
This commit is contained in:
10
app/models/ApiKey.js
Normal file
10
app/models/ApiKey.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// @flow
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
class ApiKey extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export default ApiKey;
|
||||
@@ -1,3 +1,50 @@
|
||||
// @flow
|
||||
import BaseStore from 'stores/BaseStore';
|
||||
export default BaseStore;
|
||||
import { set, observable } from 'mobx';
|
||||
|
||||
export default class BaseModel {
|
||||
@observable id: string;
|
||||
@observable isSaving: boolean;
|
||||
store: *;
|
||||
|
||||
constructor(fields: Object, store: *) {
|
||||
set(this, fields);
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
save = async params => {
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
// ensure that the id is passed if the document has one
|
||||
if (params) params = { ...params, id: this.id };
|
||||
await this.store.save(params || this.toJS());
|
||||
|
||||
// if saving is successful set the new values on the model itself
|
||||
if (params) set(this, params);
|
||||
return this;
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
fetch = (options: *) => {
|
||||
return this.store.fetch(this.id, options);
|
||||
};
|
||||
|
||||
refresh = () => {
|
||||
return this.fetch({ force: true });
|
||||
};
|
||||
|
||||
delete = async () => {
|
||||
this.isSaving = true;
|
||||
try {
|
||||
return await this.store.delete(this);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
toJS = () => {
|
||||
return { ...this };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// @flow
|
||||
import { extendObservable, action, computed, runInAction } from 'mobx';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import { pick } from 'lodash';
|
||||
import { action, computed } from 'mobx';
|
||||
import BaseModel from 'models/BaseModel';
|
||||
import Document from 'models/Document';
|
||||
import { client } from 'utils/ApiClient';
|
||||
import stores from 'stores';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import type { NavigationNode } from 'types';
|
||||
|
||||
class Collection extends BaseModel {
|
||||
isSaving: boolean = false;
|
||||
ui: UiStore;
|
||||
export default class Collection extends BaseModel {
|
||||
isSaving: boolean;
|
||||
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
type: 'atlas' | 'journal';
|
||||
documents: NavigationNode[];
|
||||
updatedAt: string;
|
||||
createdAt: ?string;
|
||||
updatedAt: ?string;
|
||||
url: string;
|
||||
|
||||
@computed
|
||||
@@ -56,103 +52,11 @@ class Collection extends BaseModel {
|
||||
travelDocuments(this.documents);
|
||||
}
|
||||
|
||||
@action
|
||||
fetch = async () => {
|
||||
try {
|
||||
const res = await client.post('/collections.info', { id: this.id });
|
||||
invariant(res && res.data, 'API response should be available');
|
||||
const { data } = res;
|
||||
runInAction('Collection#fetch', () => {
|
||||
this.updateData(data);
|
||||
});
|
||||
} catch (e) {
|
||||
this.ui.showToast('Collection failed loading');
|
||||
}
|
||||
|
||||
return this;
|
||||
toJS = () => {
|
||||
return pick(this, ['name', 'color', 'description']);
|
||||
};
|
||||
|
||||
@action
|
||||
save = async () => {
|
||||
if (this.isSaving) return this;
|
||||
this.isSaving = true;
|
||||
|
||||
const params = {
|
||||
name: this.name,
|
||||
color: this.color,
|
||||
description: this.description,
|
||||
};
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (this.id) {
|
||||
res = await client.post('/collections.update', {
|
||||
id: this.id,
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
res = await client.post('/collections.create', params);
|
||||
}
|
||||
runInAction('Collection#save', () => {
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
this.updateData(res.data);
|
||||
});
|
||||
} catch (e) {
|
||||
this.ui.showToast('Collection failed saving');
|
||||
return false;
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
export = () => {
|
||||
return client.post('/collections.export', { id: this.id });
|
||||
};
|
||||
|
||||
@action
|
||||
delete = async () => {
|
||||
try {
|
||||
await client.post('/collections.delete', { id: this.id });
|
||||
this.emit('collections.delete', { id: this.id });
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.ui.showToast('Collection failed to delete');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@action
|
||||
export = async () => {
|
||||
await client.post('/collections.export', { id: this.id });
|
||||
};
|
||||
|
||||
@action
|
||||
updateData(data: Object = {}) {
|
||||
extendObservable(this, data);
|
||||
}
|
||||
|
||||
constructor(collection: $Shape<Collection>) {
|
||||
super();
|
||||
|
||||
this.updateData(collection);
|
||||
this.ui = stores.ui;
|
||||
|
||||
this.on('documents.delete', (data: { collectionId: string }) => {
|
||||
if (data.collectionId === this.id) this.fetch();
|
||||
});
|
||||
this.on(
|
||||
'documents.update',
|
||||
(data: { collectionId: string, document: Document }) => {
|
||||
if (data.collectionId === this.id) {
|
||||
this.updateDocument(data.document);
|
||||
}
|
||||
}
|
||||
);
|
||||
this.on('documents.publish', (data: { collectionId: string }) => {
|
||||
if (data.collectionId === this.id) this.fetch();
|
||||
});
|
||||
this.on('documents.move', (data: { collectionId: string }) => {
|
||||
if (data.collectionId === this.id) this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Collection;
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
/* eslint-disable */
|
||||
import Collection from './Collection';
|
||||
const { client } = require('utils/ApiClient');
|
||||
import stores from '../stores';
|
||||
|
||||
describe('Collection model', () => {
|
||||
test('should initialize with data', () => {
|
||||
const collection = new Collection({
|
||||
const collection = stores.collections.add({
|
||||
id: 123,
|
||||
name: 'Engineering',
|
||||
});
|
||||
expect(collection.name).toBe('Engineering');
|
||||
});
|
||||
|
||||
describe('#fetch', () => {
|
||||
test('should update data', async () => {
|
||||
client.post = jest.fn(() => ({
|
||||
data: {
|
||||
name: 'New collection',
|
||||
},
|
||||
}))
|
||||
|
||||
const collection = new Collection({
|
||||
id: 123,
|
||||
name: 'Engineering',
|
||||
});
|
||||
|
||||
await collection.fetch();
|
||||
expect(client.post).toHaveBeenCalledWith('/collections.info', { id: 123 });
|
||||
expect(collection.name).toBe('New collection');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,49 +1,57 @@
|
||||
// @flow
|
||||
import { extendObservable, action, runInAction, computed } from 'mobx';
|
||||
import { action, set, computed } from 'mobx';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import { client } from 'utils/ApiClient';
|
||||
import stores from 'stores';
|
||||
import parseTitle from '../../shared/utils/parseTitle';
|
||||
import unescape from '../../shared/utils/unescape';
|
||||
import parseTitle from 'shared/utils/parseTitle';
|
||||
import unescape from 'shared/utils/unescape';
|
||||
|
||||
import type { NavigationNode, Revision, User } from 'types';
|
||||
import BaseModel from './BaseModel';
|
||||
import Collection from './Collection';
|
||||
import type { NavigationNode } from 'types';
|
||||
import BaseModel from 'models/BaseModel';
|
||||
import Revision from 'models/Revision';
|
||||
import User from 'models/User';
|
||||
import Collection from 'models/Collection';
|
||||
|
||||
type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
|
||||
|
||||
class Document extends BaseModel {
|
||||
isSaving: boolean = false;
|
||||
export default class Document extends BaseModel {
|
||||
isSaving: boolean;
|
||||
ui: *;
|
||||
store: *;
|
||||
|
||||
collaborators: User[];
|
||||
collection: $Shape<Collection>;
|
||||
collection: Collection;
|
||||
collectionId: string;
|
||||
firstViewedAt: ?string;
|
||||
lastViewedAt: ?string;
|
||||
modifiedSinceViewed: ?boolean;
|
||||
createdAt: string;
|
||||
createdBy: User;
|
||||
updatedAt: string;
|
||||
updatedBy: User;
|
||||
html: string;
|
||||
id: string;
|
||||
team: string;
|
||||
starred: boolean;
|
||||
pinned: boolean;
|
||||
text: string;
|
||||
title: string;
|
||||
emoji: string;
|
||||
starred: boolean = false;
|
||||
pinned: boolean = false;
|
||||
text: string = '';
|
||||
title: string = '';
|
||||
parentDocument: ?string;
|
||||
publishedAt: ?string;
|
||||
url: string;
|
||||
urlId: string;
|
||||
shareUrl: ?string;
|
||||
views: number;
|
||||
revision: number;
|
||||
|
||||
/* Computed */
|
||||
constructor(data?: Object = {}, store: *) {
|
||||
super(data, store);
|
||||
this.updateTitle();
|
||||
}
|
||||
|
||||
@action
|
||||
updateTitle() {
|
||||
set(this, parseTitle(this.text));
|
||||
}
|
||||
|
||||
@computed
|
||||
get modifiedSinceViewed(): boolean {
|
||||
@@ -59,9 +67,8 @@ class Document extends BaseModel {
|
||||
if (childNode.id === this.id) {
|
||||
path = newPath;
|
||||
return;
|
||||
} else {
|
||||
return traveler(childNode.children, newPath);
|
||||
}
|
||||
return traveler(childNode.children, newPath);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -96,44 +103,32 @@ class Document extends BaseModel {
|
||||
: null;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
@action
|
||||
share = async () => {
|
||||
try {
|
||||
const res = await client.post('/shares.create', { documentId: this.id });
|
||||
invariant(res && res.data, 'Document API response should be available');
|
||||
|
||||
this.shareUrl = res.data.url;
|
||||
} catch (e) {
|
||||
this.ui.showToast('Document failed to share');
|
||||
}
|
||||
const res = await client.post('/shares.create', { documentId: this.id });
|
||||
invariant(res && res.data, 'Share data should be available');
|
||||
this.shareUrl = res.data.url;
|
||||
return this.shareUrl;
|
||||
};
|
||||
|
||||
@action
|
||||
restore = async (revision: Revision) => {
|
||||
try {
|
||||
const res = await client.post('/documents.restore', {
|
||||
id: this.id,
|
||||
revisionId: revision.id,
|
||||
});
|
||||
runInAction('Document#save', () => {
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
this.updateData(res.data);
|
||||
});
|
||||
} catch (e) {
|
||||
this.ui.showToast('Document failed to restore');
|
||||
}
|
||||
updateFromJson = data => {
|
||||
set(this, data);
|
||||
this.updateTitle();
|
||||
};
|
||||
|
||||
restore = (revision: Revision) => {
|
||||
return this.store.restore(this, revision);
|
||||
};
|
||||
|
||||
@action
|
||||
pin = async () => {
|
||||
this.pinned = true;
|
||||
try {
|
||||
await client.post('/documents.pin', { id: this.id });
|
||||
} catch (e) {
|
||||
await this.store.pin(this);
|
||||
} catch (err) {
|
||||
this.pinned = false;
|
||||
this.ui.showToast('Document failed to pin');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,10 +136,10 @@ class Document extends BaseModel {
|
||||
unpin = async () => {
|
||||
this.pinned = false;
|
||||
try {
|
||||
await client.post('/documents.unpin', { id: this.id });
|
||||
} catch (e) {
|
||||
await this.store.unpin(this);
|
||||
} catch (err) {
|
||||
this.pinned = true;
|
||||
this.ui.showToast('Document failed to unpin');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,10 +147,10 @@ class Document extends BaseModel {
|
||||
star = async () => {
|
||||
this.starred = true;
|
||||
try {
|
||||
await client.post('/documents.star', { id: this.id });
|
||||
} catch (e) {
|
||||
await this.store.star(this);
|
||||
} catch (err) {
|
||||
this.starred = false;
|
||||
this.ui.showToast('Document failed star');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,10 +158,10 @@ class Document extends BaseModel {
|
||||
unstar = async () => {
|
||||
this.starred = false;
|
||||
try {
|
||||
await client.post('/documents.unstar', { id: this.id });
|
||||
} catch (e) {
|
||||
this.starred = false;
|
||||
this.ui.showToast('Document failed unstar');
|
||||
await this.store.unstar(this);
|
||||
} catch (err) {
|
||||
this.starred = true;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,28 +173,21 @@ class Document extends BaseModel {
|
||||
|
||||
@action
|
||||
fetch = async () => {
|
||||
try {
|
||||
const res = await client.post('/documents.info', { id: this.id });
|
||||
invariant(res && res.data, 'Document API response should be available');
|
||||
const { data } = res;
|
||||
runInAction('Document#update', () => {
|
||||
this.updateData(data);
|
||||
});
|
||||
} catch (e) {
|
||||
this.ui.showToast('Document failed loading');
|
||||
}
|
||||
const res = await client.post('/documents.info', { id: this.id });
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
this.updateFromJson(res.data);
|
||||
};
|
||||
|
||||
@action
|
||||
save = async (options: SaveOptions) => {
|
||||
if (this.isSaving) return this;
|
||||
|
||||
const wasDraft = !this.publishedAt;
|
||||
const isCreating = !this.id;
|
||||
const wasDraft = !this.publishedAt;
|
||||
this.isSaving = true;
|
||||
this.updateTitle();
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (isCreating) {
|
||||
const data = {
|
||||
parentDocument: undefined,
|
||||
@@ -211,77 +199,30 @@ class Document extends BaseModel {
|
||||
if (this.parentDocument) {
|
||||
data.parentDocument = this.parentDocument;
|
||||
}
|
||||
res = await client.post('/documents.create', data);
|
||||
const document = await this.store.create(data);
|
||||
return document;
|
||||
} else {
|
||||
res = await client.post('/documents.update', {
|
||||
const document = await this.store.update({
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
lastRevision: this.revision,
|
||||
...options,
|
||||
});
|
||||
return document;
|
||||
}
|
||||
runInAction('Document#save', () => {
|
||||
invariant(res && res.data, 'Data should be available');
|
||||
this.updateData(res.data);
|
||||
|
||||
if (isCreating) {
|
||||
this.emit('documents.create', this);
|
||||
}
|
||||
|
||||
this.emit('documents.update', {
|
||||
document: this,
|
||||
collectionId: this.collection.id,
|
||||
});
|
||||
|
||||
if (wasDraft && this.publishedAt) {
|
||||
this.emit('documents.publish', {
|
||||
id: this.id,
|
||||
collectionId: this.collection.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this.ui.showToast('Document failed to save');
|
||||
} finally {
|
||||
if (wasDraft && options.publish) {
|
||||
this.store.rootStore.collections.fetch(this.collection.id, {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
@action
|
||||
move = async (parentDocumentId: ?string) => {
|
||||
try {
|
||||
const res = await client.post('/documents.move', {
|
||||
id: this.id,
|
||||
parentDocument: parentDocumentId,
|
||||
});
|
||||
invariant(res && res.data, 'Data not available');
|
||||
this.updateData(res.data);
|
||||
this.emit('documents.move', {
|
||||
id: this.id,
|
||||
collectionId: this.collection.id,
|
||||
});
|
||||
} catch (e) {
|
||||
this.ui.showToast('Error while moving the document');
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
@action
|
||||
delete = async () => {
|
||||
try {
|
||||
await client.post('/documents.delete', { id: this.id });
|
||||
this.emit('documents.delete', {
|
||||
id: this.id,
|
||||
collectionId: this.collection.id,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.ui.showToast('Error while deleting the document');
|
||||
}
|
||||
return false;
|
||||
move = (parentDocumentId: ?string) => {
|
||||
return this.store.move(this, parentDocumentId);
|
||||
};
|
||||
|
||||
duplicate = () => {
|
||||
@@ -289,6 +230,7 @@ class Document extends BaseModel {
|
||||
};
|
||||
|
||||
download = async () => {
|
||||
// Ensure the document is upto date with latest server contents
|
||||
await this.fetch();
|
||||
|
||||
const blob = new Blob([unescape(this.text)], { type: 'text/markdown' });
|
||||
@@ -301,23 +243,4 @@ class Document extends BaseModel {
|
||||
a.download = `${this.title}.md`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
updateData(data: Object = {}) {
|
||||
if (data.text) {
|
||||
const { title, emoji } = parseTitle(data.text);
|
||||
data.title = title;
|
||||
data.emoji = emoji;
|
||||
}
|
||||
extendObservable(this, data);
|
||||
}
|
||||
|
||||
constructor(data?: Object = {}) {
|
||||
super();
|
||||
|
||||
this.updateData(data);
|
||||
this.ui = stores.ui;
|
||||
this.store = stores.documents;
|
||||
}
|
||||
}
|
||||
|
||||
export default Document;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable */
|
||||
import Document from './Document';
|
||||
import stores from '../stores';
|
||||
|
||||
describe('Document model', () => {
|
||||
test('should initialize with data', () => {
|
||||
const document = new Document({
|
||||
const document = stores.documents.add({
|
||||
id: 123,
|
||||
text: '# Onboarding\nSome body text',
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@ import { extendObservable, action } from 'mobx';
|
||||
|
||||
import BaseModel from 'models/BaseModel';
|
||||
import { client } from 'utils/ApiClient';
|
||||
import stores from 'stores';
|
||||
import UiStore from 'stores/UiStore';
|
||||
|
||||
type Settings = {
|
||||
url: string,
|
||||
@@ -15,8 +13,6 @@ type Settings = {
|
||||
type Events = 'documents.create' | 'collections.create';
|
||||
|
||||
class Integration extends BaseModel {
|
||||
ui: UiStore;
|
||||
|
||||
id: string;
|
||||
service: string;
|
||||
collectionId: string;
|
||||
@@ -25,33 +21,15 @@ class Integration extends BaseModel {
|
||||
|
||||
@action
|
||||
update = async (data: Object) => {
|
||||
try {
|
||||
await client.post('/integrations.update', { id: this.id, ...data });
|
||||
extendObservable(this, data);
|
||||
} catch (e) {
|
||||
this.ui.showToast('Integration failed to update');
|
||||
}
|
||||
return false;
|
||||
await client.post('/integrations.update', { id: this.id, ...data });
|
||||
extendObservable(this, data);
|
||||
return true;
|
||||
};
|
||||
|
||||
@action
|
||||
delete = async () => {
|
||||
try {
|
||||
await client.post('/integrations.delete', { id: this.id });
|
||||
this.emit('integrations.delete', { id: this.id });
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.ui.showToast('Integration failed to delete');
|
||||
}
|
||||
return false;
|
||||
delete = () => {
|
||||
return this.store.delete(this);
|
||||
};
|
||||
|
||||
constructor(data?: Object = {}) {
|
||||
super();
|
||||
|
||||
extendObservable(this, data);
|
||||
this.ui = stores.ui;
|
||||
}
|
||||
}
|
||||
|
||||
export default Integration;
|
||||
|
||||
9
app/models/NotificationSetting.js
Normal file
9
app/models/NotificationSetting.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// @flow
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
class NotificationSetting extends BaseModel {
|
||||
id: string;
|
||||
event: string;
|
||||
}
|
||||
|
||||
export default NotificationSetting;
|
||||
14
app/models/Revision.js
Normal file
14
app/models/Revision.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// @flow
|
||||
import BaseModel from './BaseModel';
|
||||
import User from './User';
|
||||
|
||||
class Revision extends BaseModel {
|
||||
id: string;
|
||||
documentId: string;
|
||||
title: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
createdBy: User;
|
||||
}
|
||||
|
||||
export default Revision;
|
||||
15
app/models/Share.js
Normal file
15
app/models/Share.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import BaseModel from './BaseModel';
|
||||
import User from './User';
|
||||
|
||||
class Share extends BaseModel {
|
||||
id: string;
|
||||
url: string;
|
||||
documentTitle: string;
|
||||
documentUrl: string;
|
||||
createdBy: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default Share;
|
||||
15
app/models/Team.js
Normal file
15
app/models/Team.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
class Team extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
slackConnected: boolean;
|
||||
googleConnected: boolean;
|
||||
sharing: boolean;
|
||||
subdomain: ?string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default Team;
|
||||
15
app/models/User.js
Normal file
15
app/models/User.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// @flow
|
||||
import BaseModel from './BaseModel';
|
||||
|
||||
class User extends BaseModel {
|
||||
avatarUrl: string;
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isSuspended: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default User;
|
||||
Reference in New Issue
Block a user