diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index baba3253f..c470508b6 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -69,7 +69,6 @@ jobs: yarn build yarn lint yarn test - env: PG_HOST: localhost PG_PORT: ${{ job.services.postgres.ports[5432] }} @@ -78,3 +77,12 @@ jobs: PG_DB: omnivore_test PG_POOL_MAX: 10 ELASTIC_URL: http://localhost:${{ job.services.elastic.ports[9200] }}/ + build-docker-images: + name: Build docker images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Build the API docker image + run: 'docker build --file packages/api/Dockerfile .' diff --git a/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift new file mode 100644 index 000000000..ffb6214fb --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/ApplyLabelsView.swift @@ -0,0 +1,87 @@ +import Combine +import Models +import Services +import SwiftUI +import Views + +final class ApplyLabelsViewModel: ObservableObject { + private var hasLoadedInitialLabels = false + @Published var isLoading = true + @Published var selectedLabels = Set() + @Published var labels = [FeedItemLabel]() + + var subscriptions = Set() + + func load(item: FeedItem, dataService: DataService) { + guard !hasLoadedInitialLabels else { return } + + dataService.labelsPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels = result + self?.hasLoadedInitialLabels = true + self?.selectedLabels = Set(item.labels) + } + ) + .store(in: &subscriptions) + } + + func saveChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) { + dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink( + receiveCompletion: { _ in }, + receiveValue: { onComplete($0) } + ) + .store(in: &subscriptions) + } +} + +struct ApplyLabelsView: View { + let item: FeedItem + let commitLabelChanges: ([FeedItemLabel]) -> Void + + @EnvironmentObject var dataService: DataService + @Environment(\.presentationMode) private var presentationMode + @StateObject var viewModel = ApplyLabelsViewModel() + + var body: some View { + NavigationView { + if viewModel.isLoading { + EmptyView() + } else { + List(viewModel.labels, id: \.self, selection: $viewModel.selectedLabels) { label in + if let textChip = TextChip(feedItemLabel: label) { + textChip + } else { + Text(label.name) + } + } + .environment(\.editMode, .constant(EditMode.active)) + .navigationTitle("Apply Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button( + action: { presentationMode.wrappedValue.dismiss() }, + label: { Text("Cancel") } + ) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { + viewModel.saveChanges(itemID: item.id, dataService: dataService) { labels in + commitLabelChanges(labels) + presentationMode.wrappedValue.dismiss() + } + }, + label: { Text("Save") } + ) + } + } + } + } + .onAppear { + viewModel.load(item: item, dataService: dataService) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 63919c49c..9db48b14a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -36,7 +36,6 @@ struct GridCardNavigationLink: View { @EnvironmentObject var dataService: DataService @State private var scale = 1.0 - @State private var isActive = false let item: FeedItem let searchQuery: String @@ -51,7 +50,8 @@ struct GridCardNavigationLink: View { ZStack { NavigationLink( destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item, homeFeedViewModel: viewModel)), - isActive: $isActive + tag: item, + selection: $selectedLinkItem ) { EmptyView() } @@ -60,7 +60,7 @@ struct GridCardNavigationLink: View { scale = 0.95 DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) { scale = 1.0 - isActive = true + selectedLinkItem = item } } }) @@ -68,7 +68,7 @@ struct GridCardNavigationLink: View { viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService) } } - .aspectRatio(2.1, contentMode: .fill) + .aspectRatio(1.8, contentMode: .fill) .scaleEffect(scale) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 4c29dd93b..535eb577e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -100,6 +100,9 @@ import Views viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) } } + .onChange(of: selectedLinkItem) { _ in + viewModel.commitProgressUpdates() + } } } @@ -164,6 +167,11 @@ import Views } } } + .sheet(item: $viewModel.itemUnderLabelEdit) { item in + ApplyLabelsView(item: item) { labels in + viewModel.updateLabels(itemID: item.id, labels: labels) + } + } } } } @@ -320,13 +328,15 @@ import Views case .delete: itemToRemove = item confirmationShown = true + case .editLabels: + viewModel.itemUnderLabelEdit = item } } var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 24)], spacing: 24) { - ForEach(viewModel.items, id: \.renderID) { item in + ForEach(viewModel.items) { item in let link = GridCardNavigationLink( item: item, searchQuery: searchQuery, diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index e0ebabb8d..f5e304094 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -8,9 +8,13 @@ import Views final class HomeFeedViewModel: ObservableObject { var currentDetailViewModel: LinkItemDetailViewModel? + /// Track progress updates to be committed when user navigates back to grid view + var uncommittedReadingProgressUpdates = [String: Double]() + @Published var items = [FeedItem]() @Published var isLoading = false @Published var showPushNotificationPrimer = false + @Published var itemUnderLabelEdit: FeedItem? var cursor: String? var sendProgressUpdates = false @@ -76,6 +80,9 @@ final class HomeFeedViewModel: ObservableObject { if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 { return } + + dataService.prefetchPages(items: result.items) + self?.items = isRefresh ? result.items : (self?.items ?? []) + result.items self?.isLoading = false self?.receivedIdx = thisSearchIdx @@ -160,10 +167,27 @@ final class HomeFeedViewModel: ObservableObject { .store(in: &subscriptions) } - func updateProgress(itemID: String, progress: Double) { + /// Update `FeedItem`s with the cached reading progress values so it can animate when the + /// user navigates back to the grid view (and also avoid mutations of the grid items + /// that can cause the `NavigationView` to pop. + func commitProgressUpdates() { + for (key, value) in uncommittedReadingProgressUpdates { + updateProgress(itemID: key, progress: value) + } + uncommittedReadingProgressUpdates = [:] + } + + private func updateProgress(itemID: String, progress: Double) { guard sendProgressUpdates, let item = items.first(where: { $0.id == itemID }) else { return } if let index = items.firstIndex(of: item) { items[index].readingProgress = progress } } + + func updateLabels(itemID: String, labels: [FeedItemLabel]) { + guard let item = items.first(where: { $0.id == itemID }) else { return } + if let index = items.firstIndex(of: item) { + items[index].labels = labels + } + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift index 06f94ea3c..2d83208f3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LinkItemDetailView.swift @@ -92,7 +92,7 @@ final class LinkItemDetailViewModel: ObservableObject { case let .shareHighlight(highlightID): print("show share modal for highlight with id: \(highlightID)") case let .updateReadingProgess(progress: progress): - self?.homeFeedViewModel.updateProgress(itemID: self?.item.id ?? "", progress: Double(progress)) + self?.homeFeedViewModel.uncommittedReadingProgressUpdates[self?.item.id ?? ""] = Double(progress) } } .store(in: &newWebAppWrapperViewModel.subscriptions) diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift new file mode 100644 index 000000000..852b64e8c --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/LabelsView.swift @@ -0,0 +1,195 @@ +import Combine +import Models +import Services +import SwiftUI +import Views + +final class LabelsViewModel: ObservableObject { + private var hasLoadedInitialLabels = false + @Published var isLoading = false + @Published var labels = [FeedItemLabel]() + @Published var showCreateEmailModal = false + + var subscriptions = Set() + + func loadLabels(dataService: DataService) { + guard !hasLoadedInitialLabels else { return } + isLoading = true + + dataService.labelsPublisher().sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels = result + self?.hasLoadedInitialLabels = true + } + ) + .store(in: &subscriptions) + } + + func createLabel(dataService: DataService, name: String, color: Color, description: String?) { + isLoading = true + + dataService.createLabelPublisher( + name: name, + color: color.hex ?? "", + description: description + ).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] result in + self?.isLoading = false + self?.labels.insert(result, at: 0) + self?.showCreateEmailModal = false + } + ) + .store(in: &subscriptions) + } + + func deleteLabel(dataService: DataService, labelID: String) { + isLoading = true + + dataService.removeLabelPublisher(labelID: labelID).sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, + receiveValue: { [weak self] _ in + self?.isLoading = false + self?.labels.removeAll { $0.id == labelID } + } + ) + .store(in: &subscriptions) + } +} + +struct LabelsView: View { + @EnvironmentObject var dataService: DataService + @StateObject var viewModel = LabelsViewModel() + @State private var showDeleteConfirmation = false + @State private var labelToRemoveID: String? + + let footerText = "Use labels to create curated collections of links." + + var body: some View { + Group { + #if os(iOS) + if #available(iOS 15.0, *) { + Form { + innerBody + .alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) { + Button("Remove Link", role: .destructive) { + if let labelID = labelToRemoveID { + withAnimation { + viewModel.deleteLabel(dataService: dataService, labelID: labelID) + } + } + self.labelToRemoveID = nil + } + Button("Cancel", role: .cancel) { self.labelToRemoveID = nil } + } + } + } else { + Form { innerBody } + } + + #elseif os(macOS) + List { + innerBody + } + .listStyle(InsetListStyle()) + #endif + } + .onAppear { viewModel.loadLabels(dataService: dataService) } + } + + private var innerBody: some View { + Group { + Section(footer: Text(footerText)) { + Button( + action: { viewModel.showCreateEmailModal = true }, + label: { + HStack { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + Text("Create a new Label") + Spacer() + } + } + ) + .disabled(viewModel.isLoading) + } + + if !viewModel.labels.isEmpty { + Section(header: Text("Labels")) { + ForEach(viewModel.labels, id: \.id) { label in + HStack { + Text(label.name) + Spacer() + Button( + action: { + labelToRemoveID = label.id + showDeleteConfirmation = true + }, + label: { Image(systemName: "trash") } + ) + } + } + } + } + } + .navigationTitle("Labels") + .sheet(isPresented: $viewModel.showCreateEmailModal) { + CreateLabelView(viewModel: viewModel) + } + } +} + +struct CreateLabelView: View { + @EnvironmentObject var dataService: DataService + @ObservedObject var viewModel: LabelsViewModel + + @State private var newLabelName = "" + @State private var newLabelColor = Color.clear + + var body: some View { + NavigationView { + VStack(spacing: 16) { + TextField("Label Name", text: $newLabelName) + .keyboardType(.alphabet) + .textFieldStyle(StandardTextFieldStyle()) + ColorPicker( + newLabelColor == .clear ? "Select Color" : newLabelColor.description, + selection: $newLabelColor + ) + Button( + action: { + viewModel.createLabel( + dataService: dataService, + name: newLabelName, + color: newLabelColor, + description: nil + ) + }, + label: { Text("Create") } + ) + .buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300)) + .disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear) + Spacer() + } + .padding() + .toolbar { + ToolbarItem(placement: .automatic) { + Button( + action: { viewModel.showCreateEmailModal = false }, + label: { + Image(systemName: "xmark") + .foregroundColor(.appGrayTextContrast) + } + ) + } + } + .navigationTitle("Create New Label") + .navigationBarTitleDisplayMode(.inline) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift index ca2443130..80d6eef94 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/ProfileView.swift @@ -63,6 +63,12 @@ struct ProfileView: View { } Section { + if FeatureFlag.enableLabels { + NavigationLink(destination: LabelsView()) { + Text("Labels") + } + } + NavigationLink(destination: NewsletterEmailsView()) { Text("Emails") } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 503f67417..51bc8fca4 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -36,7 +36,7 @@ struct WebReaderContainerView: View { let messageBody = message.body as? [String: Double] if let messageBody = messageBody, let progress = messageBody["progress"] { - homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress)) + homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) } } @@ -56,7 +56,7 @@ struct WebReaderContainerView: View { if message.name == WebViewAction.readingProgressUpdate.rawValue { guard let messageBody = message.body as? [String: Double] else { return } guard let progress = messageBody["progress"] else { return } - homeFeedViewModel.updateProgress(itemID: item.id, progress: Double(progress)) + homeFeedViewModel.uncommittedReadingProgressUpdates[item.id] = Double(progress) } } @@ -204,7 +204,7 @@ struct WebReaderContainerView: View { Color.systemBackground .transition(.opacity) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { withAnimation(.linear(duration: 0.2)) { showOverlay = false } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 1dd5aa8dd..2afceda35 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -10,24 +10,28 @@ struct SafariWebLink: Identifiable { } func encodeHighlightResult(_ highlight: Highlight) -> [String: Any]? { - let data = try? JSONEncoder().encode(highlight) - if let data = data, let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] { - return dictionary - } - return nil + guard let data = try? JSONEncoder().encode(highlight) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] } final class WebReaderViewModel: ObservableObject { @Published var isLoading = false @Published var articleContent: ArticleContent? + var slug: String? var subscriptions = Set() func loadContent(dataService: DataService, slug: String) { + self.slug = slug isLoading = true guard let viewer = dataService.currentViewer else { return } + if let content = dataService.pageFromCache(slug: slug) { + articleContent = content + // continue to load from the web if possible + } + dataService.articleContentPublisher(username: viewer.username, slug: slug).sink( receiveCompletion: { [weak self] completion in guard case .failure = completion else { return } @@ -35,6 +39,7 @@ final class WebReaderViewModel: ObservableObject { }, receiveValue: { [weak self] articleContent in self?.articleContent = articleContent + dataService.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug)) } ) .store(in: &subscriptions) @@ -167,12 +172,16 @@ final class WebReaderViewModel: ObservableObject { switch actionID { case "deleteHighlight": + dataService.invalidateCachedPage(slug: slug) deleteHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "createHighlight": + dataService.invalidateCachedPage(slug: slug) createHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "mergeHighlight": + dataService.invalidateCachedPage(slug: slug) mergeHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "updateHighlight": + dataService.invalidateCachedPage(slug: slug) updateHighlight(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) case "articleReadingProgress": updateReadingProgress(messageBody: messageBody, replyHandler: replyHandler, dataService: dataService) diff --git a/apple/OmnivoreKit/Sources/Models/ArticleContent.swift b/apple/OmnivoreKit/Sources/Models/ArticleContent.swift index bc55cf570..032ccf434 100644 --- a/apple/OmnivoreKit/Sources/Models/ArticleContent.swift +++ b/apple/OmnivoreKit/Sources/Models/ArticleContent.swift @@ -1,5 +1,15 @@ import Foundation +public class CachedPageContent: NSObject { + public let slug: String + public let value: ArticleContent + + public init(_ slug: String, _ content: ArticleContent) { + self.slug = slug + self.value = content + } +} + public struct ArticleContent { public let htmlContent: String public let highlights: [Highlight] diff --git a/apple/OmnivoreKit/Sources/Models/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/FeedItem.swift index c8f5a418e..42ff019ea 100644 --- a/apple/OmnivoreKit/Sources/Models/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/FeedItem.swift @@ -12,7 +12,6 @@ public struct HomeFeedData { public struct FeedItem: Identifiable, Hashable, Decodable { public let id: String - public let renderID = UUID() public let title: String public let createdAt: Date public let savedAt: Date @@ -29,6 +28,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable { public let slug: String public let isArchived: Bool public let contentReader: String? + public var labels: [FeedItemLabel] public init( id: String, @@ -47,7 +47,8 @@ public struct FeedItem: Identifiable, Hashable, Decodable { publishDate: Date?, slug: String, isArchived: Bool, - contentReader: String? + contentReader: String?, + labels: [FeedItemLabel] ) { self.id = id self.title = title @@ -66,10 +67,12 @@ public struct FeedItem: Identifiable, Hashable, Decodable { self.slug = slug self.isArchived = isArchived self.contentReader = contentReader + self.labels = labels } enum CodingKeys: String, CodingKey { - case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url + // swiftlint:disable:next line_length + case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url, labels } public init(from decoder: Decoder) throws { @@ -86,6 +89,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable { contentReader = try container.decode(String.self, forKey: .contentReader) pageURLString = try container.decode(String.self, forKey: .url) isArchived = try container.decode(Bool.self, forKey: .isArchived) + labels = try container.decode([FeedItemLabel].self, forKey: .labels) self.onDeviceImageURLString = nil self.documentDirectoryPath = nil diff --git a/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift b/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift new file mode 100644 index 000000000..14a157a02 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/FeedItemLabel.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct FeedItemLabel: Decodable, Hashable { + public let id: String + public let name: String + public let color: String + public let createdAt: Date? + public let description: String? + + public init( + id: String, + name: String, + color: String, + createdAt: Date?, + description: String? + ) { + self.id = id + self.name = name + self.color = color + self.createdAt = createdAt + self.description = description + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift index 12deb1da3..12e72e406 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/DataService.swift @@ -1,6 +1,16 @@ +import Combine import Foundation import Models +public class CacheManager: NSObject, NSCacheDelegate { + public func cache(_: NSCache, willEvictObject obj: Any) { + // This is just used for debugging + if let content = obj as? CachedPageContent { + print("evicting page from cache", content.slug) + } + } +} + public final class DataService: ObservableObject { public static var registerIntercomUser: ((String) -> Void)? public static var showIntercomMessenger: (() -> Void)? @@ -9,12 +19,20 @@ public final class DataService: ObservableObject { public internal(set) var currentViewer: Viewer? let networker: Networker + public let pageCache = NSCache() + let pageCacheQueue = DispatchQueue.global(qos: .background) + let highlightsCache = NSCache() let highlightsCacheQueue = DispatchQueue(label: "app.omnivore.highlights.cache.queue", attributes: .concurrent) + let cacheManager: CacheManager + var subscriptions = Set() + public init(appEnvironment: AppEnvironment, networker: Networker) { self.appEnvironment = appEnvironment self.networker = networker + self.cacheManager = CacheManager() + pageCache.delegate = cacheManager } public func clearHighlights() { @@ -30,3 +48,37 @@ public final class DataService: ObservableObject { } } } + +public extension DataService { + func prefetchPages(items: [FeedItem]) { + print("prefetching pages") + guard let viewer = currentViewer else { return } + + for item in items { + let slug = item.slug + articleContentPublisher(username: viewer.username, slug: slug).sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] articleContent in + self?.pageCache.setObject(CachedPageContent(slug, articleContent), forKey: NSString(string: slug)) + } + ) + .store(in: &subscriptions) + } + } + + func pageFromCache(slug: String) -> ArticleContent? { + if let content = pageCache.object(forKey: NSString(string: slug)) { + print("cache hit", slug) + return content.value + } else { + print("cache miss", slug) + } + return nil + } + + func invalidateCachedPage(slug: String?) { + if let slug = slug { + pageCache.removeObject(forKey: NSString(string: slug)) + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 89528dbc1..6f50d6b8b 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -2970,8 +2970,11 @@ extension Objects { let savedByViewer: [String: Bool] let shareInfo: [String: Objects.LinkShareInfo] let sharedComment: [String: String] + let siteIcon: [String: String] + let siteName: [String: String] let slug: [String: String] let title: [String: String] + let uploadFileId: [String: String] let url: [String: String] enum TypeName: String, Codable { @@ -3088,6 +3091,14 @@ extension Objects.Article: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "siteIcon": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "siteName": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "slug": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -3096,6 +3107,10 @@ extension Objects.Article: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "uploadFileId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "url": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -3134,8 +3149,11 @@ extension Objects.Article: Decodable { savedByViewer = map["savedByViewer"] shareInfo = map["shareInfo"] sharedComment = map["sharedComment"] + siteIcon = map["siteIcon"] + siteName = map["siteName"] slug = map["slug"] title = map["title"] + uploadFileId = map["uploadFileId"] url = map["url"] } } @@ -3587,6 +3605,51 @@ extension Fields where TypeLock == Objects.Article { return selection.mock() } } + + func uploadFileId() throws -> String? { + let field = GraphQLField.leaf( + name: "uploadFileId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.uploadFileId[field.alias!] + case .mocking: + return nil + } + } + + func siteName() throws -> String? { + let field = GraphQLField.leaf( + name: "siteName", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.siteName[field.alias!] + case .mocking: + return nil + } + } + + func siteIcon() throws -> String? { + let field = GraphQLField.leaf( + name: "siteIcon", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.siteIcon[field.alias!] + case .mocking: + return nil + } + } } extension Selection where TypeLock == Never, Type == Never { @@ -10784,7 +10847,7 @@ extension Fields where TypeLock == Objects.Label { } } - func createdAt() throws -> DateTime { + func createdAt() throws -> DateTime? { let field = GraphQLField.leaf( name: "createdAt", arguments: [] @@ -10793,12 +10856,9 @@ extension Fields where TypeLock == Objects.Label { switch response { case let .decoding(data): - if let data = data.createdAt[field.alias!] { - return data - } - throw HttpError.badpayload + return data.createdAt[field.alias!] case .mocking: - return DateTime.mockValue + return nil } } } @@ -16779,6 +16839,10 @@ extension Enums { /// SortBy enum SortBy: String, CaseIterable, Codable { case updatedTime = "UPDATED_TIME" + + case score = "SCORE" + + case savedAt = "SAVED_AT" } } @@ -16956,6 +17020,8 @@ extension Enums { case payloadTooLarge = "PAYLOAD_TOO_LARGE" case uploadFileMissing = "UPLOAD_FILE_MISSING" + + case elasticError = "ELASTIC_ERROR" } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift new file mode 100644 index 000000000..6400f87cf --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/CreateLabelPublisher.swift @@ -0,0 +1,62 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func createLabelPublisher( + name: String, + color: String, + description: String? + ) -> AnyPublisher { + enum MutationResult { + case saved(label: FeedItemLabel) + case error(errorCode: Enums.CreateLabelErrorCode) + } + + let selection = Selection { + try $0.on( + createLabelSuccess: .init { .saved(label: try $0.label(selection: feedItemLabelSelection)) }, + createLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) } + ) + } + + let mutation = Selection.Mutation { + try $0.createLabel( + input: InputObjects.CreateLabelInput( + name: name, + color: color, + description: OptionalArgument(description) + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(mutation, to: path, headers: headers) { result in + switch result { + case let .success(payload): + if let graphqlError = payload.errors { + promise(.failure(.message(messageText: "graphql error: \(graphqlError)"))) + } + + switch payload.data { + case let .saved(label: label): + promise(.success(label)) + case let .error(errorCode: errorCode): + promise(.failure(.message(messageText: errorCode.rawValue))) + } + case .failure: + promise(.failure(.message(messageText: "graphql error"))) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift new file mode 100644 index 000000000..134954a4f --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/RemoveLabelPublisher.swift @@ -0,0 +1,53 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func removeLabelPublisher(labelID: String) -> AnyPublisher { + enum MutationResult { + case success(labelID: String) + case error(errorCode: Enums.DeleteLabelErrorCode) + } + + let selection = Selection { + try $0.on( + deleteLabelSuccess: .init { + .success(labelID: try $0.label(selection: Selection.Label { try $0.id() })) + }, + deleteLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) } + ) + } + + let mutation = Selection.Mutation { + try $0.deleteLabel(id: labelID, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(mutation, to: path, headers: headers) { result in + switch result { + case let .success(payload): + if payload.errors != nil { + promise(.failure(.message(messageText: "Error removing label"))) + } + + switch payload.data { + case .success: + promise(.success(true)) + case .error: + promise(.failure(.message(messageText: "Error removing label"))) + } + case .failure: + promise(.failure(.message(messageText: "Error removing label"))) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift new file mode 100644 index 000000000..fce103d6a --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateArticleLabelsPublisher.swift @@ -0,0 +1,57 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func updateArticleLabelsPublisher(itemID: String, labelIDs: [String]) -> AnyPublisher<[FeedItemLabel], BasicError> { + enum MutationResult { + case saved(feedItem: [FeedItemLabel]) + case error(errorCode: Enums.SetLabelsErrorCode) + } + + let selection = Selection { + try $0.on( + setLabelsSuccess: .init { .saved(feedItem: try $0.labels(selection: feedItemLabelSelection.list)) }, + setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) } + ) + } + + let mutation = Selection.Mutation { + try $0.setLabels( + input: InputObjects.SetLabelsInput( + linkId: itemID, + labelIds: labelIDs + ), + selection: selection + ) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(mutation, to: path, headers: headers) { result in + switch result { + case let .success(payload): + if let graphqlError = payload.errors { + promise(.failure(.message(messageText: graphqlError.first.debugDescription))) + } + + switch payload.data { + case let .saved(labels): + promise(.success(labels)) + case .error: + promise(.failure(.message(messageText: "failed to set labels"))) + } + case .failure: + promise(.failure(.message(messageText: "failed to set labels"))) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index 4970c2330..8822a0d98 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -3,7 +3,6 @@ import Foundation import Models import SwiftGraphQL -// swiftlint:disable:next function_body_length public extension DataService { func articleContentPublisher(username: String, slug: String) -> AnyPublisher { enum QueryResult { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift new file mode 100644 index 000000000..5cce49647 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LabelsPublisher.swift @@ -0,0 +1,49 @@ +import Combine +import Foundation +import Models +import SwiftGraphQL + +public extension DataService { + func labelsPublisher() -> AnyPublisher<[FeedItemLabel], ServerError> { + enum QueryResult { + case success(result: [FeedItemLabel]) + case error(error: String) + } + + let selection = Selection { + try $0.on(labelsSuccess: .init { + QueryResult.success(result: try $0.labels(selection: feedItemLabelSelection.list)) + }, + labelsError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }) + } + + let query = Selection.Query { + try $0.labels(selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return Deferred { + Future { promise in + send(query, to: path, headers: headers) { result in + switch result { + case let .success(payload): + switch payload.data { + case let .success(result: result): + promise(.success(result)) + case .error: + promise(.failure(.unknown)) + } + case .failure: + promise(.failure(.unknown)) + } + } + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift index 7419e2649..532ffe88f 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/LibraryItemsQuery.swift @@ -143,7 +143,8 @@ let homeFeedItemSelection = Selection.Article { publishDate: try $0.publishedAt()?.value, slug: try $0.slug(), isArchived: try $0.isArchived(), - contentReader: try $0.contentReader().rawValue + contentReader: try $0.contentReader().rawValue, + labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [] ) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift new file mode 100644 index 000000000..998dc8dea --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeedItemLabelSelection.swift @@ -0,0 +1,12 @@ +import Models +import SwiftGraphQL + +let feedItemLabelSelection = Selection.Label { + FeedItemLabel( + id: try $0.id(), + name: try $0.name(), + color: try $0.color(), + createdAt: try $0.createdAt()?.value, + description: try $0.description() + ) +} diff --git a/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift new file mode 100644 index 000000000..7718fbf88 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Utils/ColorUtils.swift @@ -0,0 +1,63 @@ +import SwiftUI + +public extension Color { + /// Inititializes a `Color` from a hex value + /// - Parameter hex: Color hex value. ex: `#FFFFFF` + /// + init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + + var red: CGFloat = 0.0 + var green: CGFloat = 0.0 + var blue: CGFloat = 0.0 + var alpha: CGFloat = 1.0 + + let length = hexSanitized.count + + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } + + if length == 6 { + red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 + green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 + blue = CGFloat(rgb & 0x0000FF) / 255.0 + } else if length == 8 { + red = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0 + green = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0 + blue = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0 + alpha = CGFloat(rgb & 0x0000_00FF) / 255.0 + + } else { + return nil + } + + self.init(red: red, green: green, blue: blue, opacity: alpha) + } + + var hex: String? { + if let hexValue = toHex() { + return "#\(hexValue)" + } else { + return nil + } + } + + private func toHex() -> String? { + let uic = UIColor(self) + guard let components = uic.cgColor.components, components.count >= 3 else { + return nil + } + let red = Float(components[0]) + let green = Float(components[1]) + let blue = Float(components[2]) + + return String( + format: "%02lX%02lX%02lX", + lroundf(red * 255), + lroundf(green * 255), + lroundf(blue * 255) + ) + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index a06f1d9e8..ab5196f5a 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -14,6 +14,6 @@ public enum FeatureFlag { public static let enablePushNotifications = false public static let enableShareButton = false public static let enableSnooze = false - public static let showFeedItemTags = false + public static let enableLabels = true public static let useLocalWebView = true } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index e098badda..55fef0e3b 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -5,6 +5,7 @@ import Utils public enum GridCardAction { case toggleArchiveStatus case delete + case editLabels } public struct GridCard: View { @@ -42,6 +43,10 @@ public struct GridCard: View { var contextMenuView: some View { Group { + Button( + action: { menuActionHandler(.editLabels) }, + label: { Label("Edit Labels", systemImage: "tag") } + ) Button( action: { menuActionHandler(.toggleArchiveStatus) }, label: { @@ -156,11 +161,12 @@ public struct GridCard: View { .onTapGesture { tapHandler() } // Category Labels - if FeatureFlag.showFeedItemTags { + if FeatureFlag.enableLabels { ScrollView(.horizontal, showsIndicators: false) { HStack { - TextChip(text: "label", color: .red) - TextChip(text: "longer label", color: .blue) + ForEach(item.labels, id: \.self) { + TextChip(feedItemLabel: $0) + } Spacer() } .frame(height: 30) diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 775ad6854..b25bb7c94 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -1,11 +1,25 @@ +import Models import SwiftUI +import Utils + +public struct TextChip: View { + public init(text: String, color: Color) { + self.text = text + self.color = color + } + + public init?(feedItemLabel: FeedItemLabel) { + guard let color = Color(hex: feedItemLabel.color) else { return nil } + + self.text = feedItemLabel.name + self.color = color + } -struct TextChip: View { let text: String let color: Color let cornerRadius = 20.0 - var body: some View { + public var body: some View { Text(text) .padding(.horizontal, 10) .padding(.vertical, 5) diff --git a/docker-compose.yml b/docker-compose.yml index f773466f6..78301c35b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,7 @@ services: - PG_PORT=5432 - PG_POOL_MAX=20 - ELASTIC_URL=http://elastic:9200 + - JAEGER_HOST=jaeger - IMAGE_PROXY_URL=http://localhost:9999 - IMAGE_PROXY_SECRET=some-secret - JWT_SECRET=some_secret diff --git a/package.json b/package.json index f42ccfb0e..4985ac42d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "license": "UNLICENSED", "scripts": { - "test": "lerna run test --ignore @omnivore/web", + "test": "lerna run --no-bail test --ignore @omnivore/web", "lint": "lerna run lint --ignore @omnivore/web", "build": "lerna run build --ignore @omnivore/web", "bootstrap": "lerna bootstrap", diff --git a/packages/api/.env.test b/packages/api/.env.test index cb55a1b02..d3b940a69 100644 --- a/packages/api/.env.test +++ b/packages/api/.env.test @@ -27,3 +27,5 @@ PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper' SEGMENT_WRITE_KEY='test' REMINDER_TASK_HANDLER_URL=http://localhost:4000/svc/reminders/trigger PUBSUB_VERIFICATION_TOKEN='123456' +PUPPETEER_TASK_HANDLER_URL=http://localhost:9090/ + diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 603154477..377397b16 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -2,7 +2,6 @@ FROM node:14.18-alpine as builder WORKDIR /app -ENV NODE_ENV production ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true COPY package.json . @@ -14,7 +13,7 @@ COPY .eslintrc . COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json COPY /packages/api/package.json ./packages/api/package.json -RUN yarn install --pure-lockfile --production +RUN yarn install --pure-lockfile ADD /packages/readabilityjs ./packages/readabilityjs ADD /packages/api ./packages/api @@ -22,7 +21,10 @@ ADD /packages/api ./packages/api RUN yarn RUN yarn workspace @omnivore/api build - +# After building, fetch the production dependencies +RUN rm -rf /app/packages/api/node_modules +RUN rm -rf /app/node_modules +RUN yarn install --pure-lockfile --production FROM node:14.18-alpine as runner @@ -38,7 +40,7 @@ COPY --from=builder /app/packages/api/package.json /app/packages/api/package.jso COPY --from=builder /app/packages/api/node_modules /app/packages/api/node_modules COPY --from=builder /app/node_modules /app/node_modules COPY --from=builder /app/package.json /app/package.json - +COPY --from=builder /app/packages/api/index_settings.json /app/packages/api/index_settings.json EXPOSE 8080 CMD ["yarn", "workspace", "@omnivore/api", "start"] diff --git a/packages/api/package.json b/packages/api/package.json index 3fdc6ded2..9d0cfacb6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -30,32 +30,12 @@ "@opentelemetry/instrumentation-pg": "^0.24.0", "@opentelemetry/node": "^0.24.0", "@opentelemetry/resources": "^0.24.0", - "@opentelemetry/semantic-conventions": "^0.24.0", + "@opentelemetry/semantic-conventions": "^1.0.1", "@opentelemetry/tracing": "^0.24.0", "@sendgrid/mail": "^7.6.0", "@sentry/integrations": "^6.19.1", "@sentry/node": "^5.26.0", "@sentry/tracing": "^5.26.0", - "@types/analytics-node": "^3.1.7", - "@types/bcryptjs": "^2.4.2", - "@types/chai": "^4.2.18", - "@types/chai-string": "^1.4.2", - "@types/cookie": "^0.4.0", - "@types/cookie-parser": "^1.4.2", - "@types/dompurify": "^2.0.4", - "@types/express": "^4.17.7", - "@types/highlightjs": "^9.12.2", - "@types/intercom-client": "^2.11.8", - "@types/jsdom": "^16.2.3", - "@types/jsonwebtoken": "^8.5.0", - "@types/luxon": "^1.25.0", - "@types/mocha": "^8.2.2", - "@types/oauth": "^0.9.1", - "@types/sanitize-html": "^1.27.1", - "@types/supertest": "^2.0.11", - "@types/urlsafe-base64": "^1.0.28", - "@types/uuid": "^8.3.0", - "@types/voca": "^1.4.0", "analytics-node": "^6.0.0", "apollo-datasource": "^3.3.1", "apollo-server-express": "^3.6.3", @@ -82,8 +62,9 @@ "jwks-rsa": "^2.0.3", "knex": "0.21.12", "knex-stringcase": "^1.4.2", - "luxon": "^1.25.0", + "luxon": "^2.3.1", "nanoid": "^3.1.25", + "nodemailer": "^6.7.3", "normalize-url": "^6.1.0", "oauth": "^0.9.15", "pg": "^8.3.3", @@ -94,8 +75,8 @@ "snake-case": "^3.0.3", "supertest": "^6.2.2", "ts-loader": "^8.0.3", - "typeorm": "^0.2.37", - "typeorm-naming-strategies": "^2.0.0", + "typeorm": "^0.3.4", + "typeorm-naming-strategies": "^4.1.0", "urlsafe-base64": "^1.0.0", "uuid": "^8.3.1", "voca": "^1.4.0", @@ -104,10 +85,29 @@ "devDependencies": { "@babel/register": "^7.14.5", "@istanbuljs/nyc-config-typescript": "^1.0.2", - "@types/analytics-node": "^3.1.7", "@types/highlightjs": "^9.12.2", "@types/nanoid": "^3.0.0", "@types/private-ip": "^1.0.0", + "@types/analytics-node": "^3.1.7", + "@types/bcryptjs": "^2.4.2", + "@types/chai": "^4.2.18", + "@types/chai-string": "^1.4.2", + "@types/cookie": "^0.4.0", + "@types/cookie-parser": "^1.4.2", + "@types/dompurify": "^2.0.4", + "@types/express": "^4.17.7", + "@types/intercom-client": "^2.11.8", + "@types/jsdom": "^16.2.3", + "@types/jsonwebtoken": "^8.5.0", + "@types/luxon": "^1.25.0", + "@types/mocha": "^8.2.2", + "@types/nodemailer": "^6.4.4", + "@types/oauth": "^0.9.1", + "@types/sanitize-html": "^1.27.1", + "@types/supertest": "^2.0.11", + "@types/urlsafe-base64": "^1.0.28", + "@types/uuid": "^8.3.0", + "@types/voca": "^1.4.0", "chai": "^4.3.4", "chai-string": "^1.5.0", "circular-dependency-plugin": "^5.2.0", diff --git a/packages/api/src/entity/follower.ts b/packages/api/src/entity/follower.ts index f00510059..9932cd8d1 100644 --- a/packages/api/src/entity/follower.ts +++ b/packages/api/src/entity/follower.ts @@ -1,16 +1,15 @@ import { - Entity, - BaseEntity, - PrimaryGeneratedColumn, CreateDateColumn, - OneToOne, + Entity, JoinColumn, + OneToOne, + PrimaryGeneratedColumn, } from 'typeorm' import { User } from './user' @Entity({ name: 'user_friends' }) -export class Follower extends BaseEntity { +export class Follower { @PrimaryGeneratedColumn('uuid') id?: string diff --git a/packages/api/src/entity/groups/group.ts b/packages/api/src/entity/groups/group.ts index f5a25ba76..bce8d2234 100644 --- a/packages/api/src/entity/groups/group.ts +++ b/packages/api/src/entity/groups/group.ts @@ -1,18 +1,17 @@ import { - Entity, - BaseEntity, Column, - PrimaryGeneratedColumn, CreateDateColumn, - UpdateDateColumn, - OneToOne, + Entity, JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm' import { User } from '../user' @Entity() -export class Group extends BaseEntity { +export class Group { @PrimaryGeneratedColumn('uuid') id?: string diff --git a/packages/api/src/entity/groups/group_membership.ts b/packages/api/src/entity/groups/group_membership.ts index d33401701..1cb1e537c 100644 --- a/packages/api/src/entity/groups/group_membership.ts +++ b/packages/api/src/entity/groups/group_membership.ts @@ -1,11 +1,10 @@ import { - Entity, - BaseEntity, - PrimaryGeneratedColumn, CreateDateColumn, - UpdateDateColumn, - OneToOne, + Entity, JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm' import { User } from '../user' @@ -13,7 +12,7 @@ import { Group } from './group' import { Invite } from './invite' @Entity() -export class GroupMembership extends BaseEntity { +export class GroupMembership { @PrimaryGeneratedColumn('uuid') id?: string diff --git a/packages/api/src/entity/groups/invite.ts b/packages/api/src/entity/groups/invite.ts index 70e3cc37e..7dce6fab7 100644 --- a/packages/api/src/entity/groups/invite.ts +++ b/packages/api/src/entity/groups/invite.ts @@ -1,19 +1,18 @@ import { - Entity, - BaseEntity, Column, - PrimaryGeneratedColumn, CreateDateColumn, - UpdateDateColumn, - OneToOne, + Entity, JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm' import { User } from '../user' import { Group } from './group' @Entity() -export class Invite extends BaseEntity { +export class Invite { @PrimaryGeneratedColumn('uuid') id?: string diff --git a/packages/api/src/entity/highlight.ts b/packages/api/src/entity/highlight.ts index d2f6ff4d4..6ef44d9af 100644 --- a/packages/api/src/entity/highlight.ts +++ b/packages/api/src/entity/highlight.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -12,7 +11,7 @@ import { User } from './user' import { Page } from './page' @Entity({ name: 'highlight' }) -export class Highlight extends BaseEntity { +export class Highlight { @PrimaryGeneratedColumn('uuid') id?: string diff --git a/packages/api/src/entity/label.ts b/packages/api/src/entity/label.ts index 0b36eddec..fc45f2179 100644 --- a/packages/api/src/entity/label.ts +++ b/packages/api/src/entity/label.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -10,7 +9,7 @@ import { import { User } from './user' @Entity({ name: 'labels' }) -export class Label extends BaseEntity { +export class Label { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/link.ts b/packages/api/src/entity/link.ts index 40c0f208a..f53d6e8d7 100644 --- a/packages/api/src/entity/link.ts +++ b/packages/api/src/entity/link.ts @@ -10,7 +10,6 @@ // shared_with_highlights | boolean | | | false import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -27,7 +26,7 @@ import { Page } from './page' import { Label } from './label' @Entity({ name: 'links' }) -export class Link extends BaseEntity { +export class Link { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/link_label.ts b/packages/api/src/entity/link_label.ts index 5bcdd64d6..744393c3c 100644 --- a/packages/api/src/entity/link_label.ts +++ b/packages/api/src/entity/link_label.ts @@ -1,5 +1,4 @@ import { - BaseEntity, CreateDateColumn, Entity, JoinColumn, @@ -10,7 +9,7 @@ import { Link } from './link' import { Label } from './label' @Entity({ name: 'link_labels' }) -export class LinkLabel extends BaseEntity { +export class LinkLabel { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/newsletter_email.ts b/packages/api/src/entity/newsletter_email.ts index d69e97298..bdd4beac0 100644 --- a/packages/api/src/entity/newsletter_email.ts +++ b/packages/api/src/entity/newsletter_email.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -11,7 +10,7 @@ import { import { User } from './user' @Entity({ name: 'newsletter_emails' }) -export class NewsletterEmail extends BaseEntity { +export class NewsletterEmail { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/page.ts b/packages/api/src/entity/page.ts index 2f9065977..2ac8abbee 100644 --- a/packages/api/src/entity/page.ts +++ b/packages/api/src/entity/page.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -8,7 +7,7 @@ import { } from 'typeorm' @Entity({ name: 'pages' }) -export class Page extends BaseEntity { +export class Page { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/profile.ts b/packages/api/src/entity/profile.ts index 6c5313f36..a30d057cf 100644 --- a/packages/api/src/entity/profile.ts +++ b/packages/api/src/entity/profile.ts @@ -1,18 +1,17 @@ import { - Entity, - BaseEntity, Column, - PrimaryGeneratedColumn, CreateDateColumn, - UpdateDateColumn, - OneToOne, + Entity, JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm' import { User } from './user' @Entity({ name: 'user_profile' }) -export class Profile extends BaseEntity { +export class Profile { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/reminder.ts b/packages/api/src/entity/reminder.ts index 6b0fabaa4..e5dcd9ae1 100644 --- a/packages/api/src/entity/reminder.ts +++ b/packages/api/src/entity/reminder.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -11,7 +10,7 @@ import { import { User } from './user' @Entity({ name: 'reminders' }) -export class Reminder extends BaseEntity { +export class Reminder { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/reports/abuse_report.ts b/packages/api/src/entity/reports/abuse_report.ts index a38b0b939..8da8c1444 100644 --- a/packages/api/src/entity/reports/abuse_report.ts +++ b/packages/api/src/entity/reports/abuse_report.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -9,7 +8,7 @@ import { import { ReportType } from '../../generated/graphql' @Entity() -export class AbuseReport extends BaseEntity { +export class AbuseReport { @PrimaryGeneratedColumn('uuid') id?: string diff --git a/packages/api/src/entity/reports/content_display_report.ts b/packages/api/src/entity/reports/content_display_report.ts index d5396d010..b15b45d41 100644 --- a/packages/api/src/entity/reports/content_display_report.ts +++ b/packages/api/src/entity/reports/content_display_report.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -8,7 +7,7 @@ import { } from 'typeorm' @Entity() -export class ContentDisplayReport extends BaseEntity { +export class ContentDisplayReport { @PrimaryGeneratedColumn('uuid') id?: string diff --git a/packages/api/src/entity/upload_file.ts b/packages/api/src/entity/upload_file.ts index 937777048..7547b4e76 100644 --- a/packages/api/src/entity/upload_file.ts +++ b/packages/api/src/entity/upload_file.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -11,7 +10,7 @@ import { import { User } from './user' @Entity({ name: 'upload_files' }) -export class UploadFile extends BaseEntity { +export class UploadFile { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/user.ts b/packages/api/src/entity/user.ts index 5aa1e5d05..1b7a55da3 100644 --- a/packages/api/src/entity/user.ts +++ b/packages/api/src/entity/user.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -14,7 +13,7 @@ import { Profile } from './profile' import { Label } from './label' @Entity() -export class User extends BaseEntity { +export class User { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/user_device_tokens.ts b/packages/api/src/entity/user_device_tokens.ts index 79c33a45c..0f642d245 100644 --- a/packages/api/src/entity/user_device_tokens.ts +++ b/packages/api/src/entity/user_device_tokens.ts @@ -1,5 +1,4 @@ import { - BaseEntity, Column, CreateDateColumn, Entity, @@ -10,7 +9,7 @@ import { import { User } from './user' @Entity({ name: 'user_device_tokens' }) -export class UserDeviceToken extends BaseEntity { +export class UserDeviceToken { @PrimaryGeneratedColumn('uuid') id!: string diff --git a/packages/api/src/entity/utils.ts b/packages/api/src/entity/utils.ts index 2127099cb..1ee7933cc 100644 --- a/packages/api/src/entity/utils.ts +++ b/packages/api/src/entity/utils.ts @@ -1,4 +1,5 @@ -import { EntityManager } from 'typeorm' +import { EntityManager, EntityTarget, Repository } from 'typeorm' +import { AppDataSource } from '../server' export const setClaims = async ( t: EntityManager, @@ -9,3 +10,7 @@ export const setClaims = async ( .query('SELECT * from omnivore.set_claims($1, $2)', [uid, dbRole]) .then() } + +export const getRepository = (entity: EntityTarget): Repository => { + return AppDataSource.getRepository(entity) +} diff --git a/packages/api/src/events/user/user_created.ts b/packages/api/src/events/user/user_created.ts index f634f73d4..71d49db17 100644 --- a/packages/api/src/events/user/user_created.ts +++ b/packages/api/src/events/user/user_created.ts @@ -32,8 +32,7 @@ export class FollowOmnivoreUser implements EntitySubscriberInterface { await event.manager .getRepository(Follower) - .create({ user: event.entity.user, followee: omnivoreProfile.user }) - .save() + .save({ user: event.entity.user, followee: omnivoreProfile.user }) await event.manager.query( `insert into omnivore.links (user_id, article_id, article_url, article_hash, slug) diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts index e74454313..3b0301ce0 100644 --- a/packages/api/src/resolvers/labels/index.ts +++ b/packages/api/src/resolvers/labels/index.ts @@ -24,10 +24,11 @@ import { analytics } from '../../utils/analytics' import { env } from '../../env' import { User } from '../../entity/user' import { Label } from '../../entity/label' -import { getManager, getRepository, ILike } from 'typeorm' -import { setClaims } from '../../entity/utils' +import { ILike, In } from 'typeorm' +import { getRepository, setClaims } from '../../entity/utils' import { deleteLabelInPages, getPageById, updatePage } from '../../elastic' import { createPubSubClient } from '../../datalayer/pubsub' +import { AppDataSource } from '../../server' export const labelsResolver = authorized( async (_obj, _params, { claims: { uid }, log }) => { @@ -80,7 +81,7 @@ export const createLabelResolver = authorized< const { name, color, description } = input try { - const user = await getRepository(User).findOne(uid) + const user = await getRepository(User).findOneBy({ id: uid }) if (!user) { return { errorCodes: [CreateLabelErrorCode.Unauthorized], @@ -88,11 +89,9 @@ export const createLabelResolver = authorized< } // Check if label already exists ignoring case of name - const existingLabel = await getRepository(Label).findOne({ - where: { - user, - name: ILike(name), - }, + const existingLabel = await getRepository(Label).findOneBy({ + user: { id: user.id }, + name: ILike(name), }) if (existingLabel) { return { @@ -100,14 +99,12 @@ export const createLabelResolver = authorized< } } - const label = await getRepository(Label) - .create({ - user, - name, - color, - description: description || '', - }) - .save() + const label = await getRepository(Label).save({ + user, + name, + color, + description: description || '', + }) analytics.track({ userId: uid, @@ -207,14 +204,15 @@ export const deleteLabelResolver = authorized< log.info('deleteLabelResolver') try { - const user = await getRepository(User).findOne(uid) + const user = await getRepository(User).findOneBy({ id: uid }) if (!user) { return { errorCodes: [DeleteLabelErrorCode.Unauthorized], } } - const label = await getRepository(Label).findOne(labelId, { + const label = await getRepository(Label).findOne({ + where: { id: labelId }, relations: ['user'], }) if (!label) { @@ -229,7 +227,7 @@ export const deleteLabelResolver = authorized< } } - const result = await getManager().transaction(async (t) => { + const result = await AppDataSource.transaction(async (t) => { await setClaims(t, uid) return t.getRepository(Label).delete(labelId) }) @@ -276,7 +274,7 @@ export const setLabelsResolver = authorized< const { linkId: pageId, labelIds } = input try { - const user = await getRepository(User).findOne(uid) + const user = await getRepository(User).findOneBy({ id: uid }) if (!user) { return { errorCodes: [SetLabelsErrorCode.Unauthorized], @@ -290,10 +288,8 @@ export const setLabelsResolver = authorized< } } - const labels = await getRepository(Label).findByIds(labelIds, { - where: { - user, - }, + const labels = await getRepository(Label).find({ + where: { id: In(labelIds), user: { id: user.id } }, relations: ['user'], }) if (labels.length !== labelIds.length) { diff --git a/packages/api/src/resolvers/newsletters/index.ts b/packages/api/src/resolvers/newsletters/index.ts index 737f3d830..d8d34a7ce 100644 --- a/packages/api/src/resolvers/newsletters/index.ts +++ b/packages/api/src/resolvers/newsletters/index.ts @@ -1,15 +1,15 @@ import { authorized } from '../../utils/helpers' import { - CreateNewsletterEmailSuccess, CreateNewsletterEmailError, CreateNewsletterEmailErrorCode, - NewsletterEmailsSuccess, - NewsletterEmailsError, - NewsletterEmailsErrorCode, + CreateNewsletterEmailSuccess, + DeleteNewsletterEmailError, DeleteNewsletterEmailErrorCode, DeleteNewsletterEmailSuccess, - DeleteNewsletterEmailError, MutationDeleteNewsletterEmailArgs, + NewsletterEmailsError, + NewsletterEmailsErrorCode, + NewsletterEmailsSuccess, } from '../../generated/graphql' import { createNewsletterEmail, @@ -19,6 +19,8 @@ import { import { NewsletterEmail } from '../../entity/newsletter_email' import { analytics } from '../../utils/analytics' import { env } from '../../env' +import { AppDataSource } from '../../server' +import { User } from '../../entity/user' export const createNewsletterEmailResolver = authorized< CreateNewsletterEmailSuccess, @@ -55,7 +57,16 @@ export const newsletterEmailsResolver = authorized< console.log('newsletterEmailsResolver') try { - const newsletterEmails = await getNewsletterEmails(claims.uid) + const user = await AppDataSource.getRepository(User).findOneBy({ + id: claims.uid, + }) + if (!user) { + return Promise.reject({ + errorCode: NewsletterEmailsErrorCode.Unauthorized, + }) + } + + const newsletterEmails = await getNewsletterEmails(user.id) return { newsletterEmails: newsletterEmails, @@ -84,10 +95,14 @@ export const deleteNewsletterEmailResolver = authorized< }) try { - const newsletterEmail = await NewsletterEmail.findOne( - args.newsletterEmailId, - { relations: ['user'] } - ) + const newsletterEmail = await AppDataSource.getRepository( + NewsletterEmail + ).findOne({ + where: { + id: args.newsletterEmailId, + }, + relations: ['user'], + }) if (!newsletterEmail) { return { diff --git a/packages/api/src/routers/svc/emails.ts b/packages/api/src/routers/svc/emails.ts index 4bb9aa6df..4bc000d81 100644 --- a/packages/api/src/routers/svc/emails.ts +++ b/packages/api/src/routers/svc/emails.ts @@ -4,6 +4,7 @@ import { sendEmail } from '../../utils/sendEmail' import { analytics } from '../../utils/analytics' import { getNewsletterEmail } from '../../services/newsletters' import { env } from '../../env' +import { v4 as uuid } from 'uuid' import { findNewsletterUrl, isProbablyNewsletter } from '../../utils/parser' import { saveNewsletterEmail } from '../../services/save_newsletter_email' @@ -59,7 +60,7 @@ export function emailsServiceRouter() { author: data.from, url: (await findNewsletterUrl(data.html)) || - 'https://omnivore.app/no_url', + 'https://omnivore.app/no_url?q' + uuid(), }) res.status(200).send('Newsletter') return diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index a87dc975a..27967ce5a 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -19,7 +19,7 @@ import { articleRouter } from './routers/article_router' import { mobileAuthRouter } from './routers/auth/mobile/mobile_auth_router' import { contentServiceRouter } from './routers/svc/content' import { localDebugRouter } from './routers/local_debug_router' -import { Connection, createConnection } from 'typeorm' +import { DataSource } from 'typeorm' import { SnakeNamingStrategy } from 'typeorm-naming-strategies' import { linkServiceRouter } from './routers/svc/links' import UserModel from './datalayer/user' @@ -57,21 +57,19 @@ export const initModels = (kx: Knex, cache = true): DataModels => ({ reminder: new ReminderModel(kx, cache), }) -const initEntities = async (): Promise => { - return createConnection({ - type: 'postgres', - host: env.pg.host, - port: env.pg.port, - schema: 'omnivore', - username: env.pg.userName, - password: env.pg.password, - database: env.pg.dbName, - logging: ['query', 'info'], - entities: [__dirname + '/entity/**/*{.js,.ts}'], - subscribers: [__dirname + '/events/**/*{.js,.ts}'], - namingStrategy: new SnakeNamingStrategy(), - }) -} +export const AppDataSource = new DataSource({ + type: 'postgres', + host: env.pg.host, + port: env.pg.port, + schema: 'omnivore', + username: env.pg.userName, + password: env.pg.password, + database: env.pg.dbName, + logging: ['query', 'info'], + entities: [__dirname + '/entity/**/*{.js,.ts}'], + subscribers: [__dirname + '/events/**/*{.js,.ts}'], + namingStrategy: new SnakeNamingStrategy(), +}) export const createApp = (): { app: Express @@ -126,7 +124,7 @@ const main = async (): Promise => { // If creating the DB entities fails, we want this to throw // so the container will be restarted and not come online // as healthy. - await initEntities() + await AppDataSource.initialize() await initElasticsearch() diff --git a/packages/api/src/services/archive_link.ts b/packages/api/src/services/archive_link.ts index 13609402b..226df4672 100644 --- a/packages/api/src/services/archive_link.ts +++ b/packages/api/src/services/archive_link.ts @@ -1,13 +1,13 @@ -import { getManager } from 'typeorm' import { Link } from '../entity/link' import { setClaims } from '../entity/utils' +import { AppDataSource } from '../server' export const setLinkArchived = async ( userId: string, linkId: string, archived: boolean ): Promise => { - await getManager().transaction(async (t) => { + await AppDataSource.transaction(async (t) => { await setClaims(t, userId) await t.getRepository(Link).update( { diff --git a/packages/api/src/services/create_group.ts b/packages/api/src/services/create_group.ts index d7f19e466..a8897460b 100644 --- a/packages/api/src/services/create_group.ts +++ b/packages/api/src/services/create_group.ts @@ -1,9 +1,9 @@ -import { getManager } from 'typeorm' import { User } from '../entity/user' import { Group } from '../entity/groups/group' import { Invite } from '../entity/groups/invite' import { GroupMembership } from '../entity/groups/group_membership' import { nanoid } from 'nanoid' +import { AppDataSource } from '../server' export const createGroup = async (input: { admin: User @@ -11,15 +11,12 @@ export const createGroup = async (input: { maxMembers?: number expiresInDays?: number }): Promise<[Group, Invite]> => { - const [group, invite] = await getManager().transaction<[Group, Invite]>( + const [group, invite] = await AppDataSource.transaction<[Group, Invite]>( async (t) => { - const group = await t - .getRepository(Group) - .create({ - name: input.name, - createdBy: input.admin, - }) - .save() + const group = await t.getRepository(Group).save({ + name: input.name, + createdBy: input.admin, + }) const code = nanoid(8) const expirationTime = (() => { @@ -27,25 +24,19 @@ export const createGroup = async (input: { r.setDate(r.getDate() + (input.expiresInDays || 7)) return r })() - const invite = await t - .getRepository(Invite) - .create({ - group, - code, - createdBy: input.admin, - maxMembers: input.maxMembers || 50, - expirationTime: expirationTime, - }) - .save() + const invite = await t.getRepository(Invite).save({ + group, + code, + createdBy: input.admin, + maxMembers: input.maxMembers || 50, + expirationTime: expirationTime, + }) // Add the admin to the group as its first user - await t - .getRepository(GroupMembership) - .create({ - user: input.admin, - group, - invite, - }) - .save() + await t.getRepository(GroupMembership).save({ + user: input.admin, + group, + invite, + }) return [group, invite] } ) diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index 44cc6f721..bd94ead22 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -1,12 +1,14 @@ import { AuthProvider } from '../routers/auth/auth_types' import { MembershipTier } from '../datalayer/user/model' -import { EntityManager, getManager, getRepository } from 'typeorm' +import { EntityManager } from 'typeorm' import { User } from '../entity/user' import { Profile } from '../entity/profile' import { SignupErrorCode } from '../generated/graphql' import { validateUsername } from '../utils/usernamePolicy' import { Invite } from '../entity/groups/invite' import { GroupMembership } from '../entity/groups/group_membership' +import { AppDataSource } from '../server' +import { getRepository } from '../entity/utils' export const createUser = async (input: { provider: AuthProvider @@ -28,15 +30,12 @@ export const createUser = async (input: { } // create profile if user exists but profile does not exist - const profile = await getManager() - .getRepository(Profile) - .create({ - username: input.username, - pictureUrl: input.pictureUrl, - bio: input.bio, - user: existingUser, - }) - .save() + const profile = await getRepository(Profile).save({ + username: input.username, + pictureUrl: input.pictureUrl, + bio: input.bio, + user: existingUser, + }) return [existingUser, profile] } @@ -45,10 +44,10 @@ export const createUser = async (input: { return Promise.reject({ errorCode: SignupErrorCode.InvalidUsername }) } - const [user, profile] = await getManager().transaction<[User, Profile]>( + const [user, profile] = await AppDataSource.transaction<[User, Profile]>( async (t) => { let hasInvite = false - let invite: Invite | undefined = undefined + let invite: Invite | null = null if (input.inviteCode) { const inviteCodeRepo = t.getRepository(Invite) @@ -60,37 +59,28 @@ export const createUser = async (input: { hasInvite = true } } - const user = await t - .getRepository(User) - .create({ - source: input.provider, - membership: - input.membershipTier || - (hasInvite ? MembershipTier.Beta : MembershipTier.WaitList), - name: input.name, - email: input.email, - sourceUserId: input.sourceUserId, - password: input.password, - }) - .save() - const profile = await t - .getRepository(Profile) - .create({ - username: input.username, - pictureUrl: input.pictureUrl, - bio: input.bio, - user, - }) - .save() + const user = await t.getRepository(User).save({ + source: input.provider, + membership: + input.membershipTier || + (hasInvite ? MembershipTier.Beta : MembershipTier.WaitList), + name: input.name, + email: input.email, + sourceUserId: input.sourceUserId, + password: input.password, + }) + const profile = await t.getRepository(Profile).save({ + username: input.username, + pictureUrl: input.pictureUrl, + bio: input.bio, + user, + }) if (hasInvite && invite) { - await t - .getRepository(GroupMembership) - .create({ - user: user, - invite: invite, - group: invite.group, - }) - .save() + await t.getRepository(GroupMembership).save({ + user: user, + invite: invite, + group: invite.group, + }) } return [user, profile] } @@ -119,7 +109,7 @@ const validateInvite = async ( return true } -const getUser = async (email: string): Promise => { +const getUser = async (email: string): Promise => { const userRepo = getRepository(User) return userRepo.findOne({ diff --git a/packages/api/src/services/followers.ts b/packages/api/src/services/followers.ts index 2cba149cd..0b93c2e81 100644 --- a/packages/api/src/services/followers.ts +++ b/packages/api/src/services/followers.ts @@ -1,6 +1,6 @@ -import { getRepository } from 'typeorm' import { User } from '../entity/user' import { Follower } from '../entity/follower' +import { getRepository } from '../entity/utils' export const getUserFollowers = async ( user: User, @@ -9,7 +9,7 @@ export const getUserFollowers = async ( ): Promise => { return ( await getRepository(Follower).find({ - where: { user: user }, + where: { user: { id: user.id } }, relations: ['user', 'followee'], skip: offset, take: count, @@ -24,7 +24,7 @@ export const getUserFollowing = async ( ): Promise => { return ( await getRepository(Follower).find({ - where: { followee: user }, + where: { followee: { id: user.id } }, relations: ['user', 'followee'], skip: offset, take: count, diff --git a/packages/api/src/services/labels.ts b/packages/api/src/services/labels.ts index daf2d798a..5b3be6915 100644 --- a/packages/api/src/services/labels.ts +++ b/packages/api/src/services/labels.ts @@ -1,10 +1,11 @@ -import DataLoader from 'dataloader' import { Label } from '../entity/label' -import { getRepository, ILike, In } from 'typeorm' -import { Link } from '../entity/link' +import { ILike, In } from 'typeorm' import { PageContext } from '../elastic/types' import { User } from '../entity/user' import { addLabelInPage } from '../elastic' +import { getRepository } from '../entity/utils' +import { Link } from '../entity/link' +import DataLoader from 'dataloader' const batchGetLabelsFromLinkIds = async ( linkIds: readonly string[] @@ -30,13 +31,16 @@ export const addLabelToPage = async ( description?: string } ): Promise => { - const user = await getRepository(User).findOne(ctx.uid) + const user = await getRepository(User).findOneBy({ + id: ctx.uid, + }) + if (!user) { + return false + } - let labelEntity = await getRepository(Label).findOne({ - where: { - user: user, - name: ILike(label.name), - }, + let labelEntity = await getRepository(Label).findOneBy({ + user: { id: user.id }, + name: ILike(label.name), }) if (!labelEntity) { diff --git a/packages/api/src/services/newsletters.ts b/packages/api/src/services/newsletters.ts index 1ff5949d6..ac6391146 100644 --- a/packages/api/src/services/newsletters.ts +++ b/packages/api/src/services/newsletters.ts @@ -1,14 +1,24 @@ -import { getRepository } from 'typeorm' import { NewsletterEmail } from '../entity/newsletter_email' import { nanoid } from 'nanoid' import { User } from '../entity/user' import { CreateNewsletterEmailErrorCode } from '../generated/graphql' import { env } from '../env' +import { getRepository } from '../entity/utils' +import addressparser = require('nodemailer/lib/addressparser') + +const parsedAddress = (emailAddress: string): string | undefined => { + const res = addressparser(emailAddress, { flatten: true }) + if (!res || res.length < 1) { + return undefined + } + return res[0].address +} export const createNewsletterEmail = async ( userId: string ): Promise => { - const user = await getRepository(User).findOne(userId, { + const user = await getRepository(User).findOne({ + where: { id: userId }, relations: ['profile'], }) if (!user) { @@ -19,19 +29,17 @@ export const createNewsletterEmail = async ( // generate a random email address with username prefix const emailAddress = createRandomEmailAddress(user.profile.username, 8) - return getRepository(NewsletterEmail) - .create({ - address: emailAddress, - user: user, - }) - .save() + return getRepository(NewsletterEmail).save({ + address: emailAddress, + user: user, + }) } export const getNewsletterEmails = async ( userId: string ): Promise => { return getRepository(NewsletterEmail).find({ - where: { user: userId }, + where: { user: { id: userId } }, order: { createdAt: 'DESC' }, }) } @@ -46,9 +54,10 @@ export const updateConfirmationCode = async ( emailAddress: string, confirmationCode: string ): Promise => { + const address = parsedAddress(emailAddress) const result = await getRepository(NewsletterEmail) .createQueryBuilder() - .where('address ILIKE :address', { address: emailAddress }) + .where('address ILIKE :address', { address }) .update({ confirmationCode: confirmationCode, }) @@ -59,11 +68,12 @@ export const updateConfirmationCode = async ( export const getNewsletterEmail = async ( emailAddress: string -): Promise => { +): Promise => { + const address = parsedAddress(emailAddress) return getRepository(NewsletterEmail) .createQueryBuilder('newsletter_email') .innerJoinAndSelect('newsletter_email.user', 'user') - .where('address ILIKE :address', { address: emailAddress }) + .where('address ILIKE :address', { address }) .getOne() } diff --git a/packages/api/src/services/reports.ts b/packages/api/src/services/reports.ts index f58e7e74b..ed545b075 100644 --- a/packages/api/src/services/reports.ts +++ b/packages/api/src/services/reports.ts @@ -1,8 +1,8 @@ -import { getRepository } from 'typeorm' import { ReportItemInput, ReportType } from '../generated/graphql' import { ContentDisplayReport } from '../entity/reports/content_display_report' import { AbuseReport } from '../entity/reports/abuse_report' import { getPageById } from '../elastic' +import { getRepository } from '../entity/utils' export const saveContentDisplayReport = async ( uid: string, @@ -20,16 +20,14 @@ export const saveContentDisplayReport = async ( // We capture the article content and original html now, in case it // reparsed or updated later, this gives us a view of exactly // what the user saw. - const result = await repo - .create({ - userId: uid, - elasticPageId: input.pageId, - content: page.content, - originalHtml: page.originalHtml || undefined, - originalUrl: page.url, - reportComment: input.reportComment, - }) - .save() + const result = await repo.save({ + userId: uid, + elasticPageId: input.pageId, + content: page.content, + originalHtml: page.originalHtml || undefined, + originalUrl: page.url, + reportComment: input.reportComment, + }) return !!result } @@ -55,16 +53,14 @@ export const saveAbuseReport = async ( // We capture the article content and original html now, in case it // reparsed or updated later, this gives us a view of exactly // what the user saw. - const result = await repo - .create({ - reportedBy: uid, - sharedBy: input.sharedBy, - elasticPageId: input.pageId, - itemUrl: input.itemUrl, - reportTypes: [ReportType.Abusive], - reportComment: input.reportComment, - }) - .save() + const result = await repo.save({ + reportedBy: uid, + sharedBy: input.sharedBy, + elasticPageId: input.pageId, + itemUrl: input.itemUrl, + reportTypes: [ReportType.Abusive], + reportComment: input.reportComment, + }) return !!result } diff --git a/packages/api/src/services/save_email.ts b/packages/api/src/services/save_email.ts index 590d983d9..a733f84be 100644 --- a/packages/api/src/services/save_email.ts +++ b/packages/api/src/services/save_email.ts @@ -12,6 +12,7 @@ import { createPage, getPageByParam, updatePage } from '../elastic' export type SaveContext = { pubsub: PubsubClient uid: string + refresh?: boolean } export type SaveEmailInput = { @@ -67,7 +68,7 @@ export const saveEmail = async ( readingProgressPercent: 0, } - const page = await getPageByParam({ url: articleToSave.url }) + const page = await getPageByParam({ userId: ctx.uid, url: articleToSave.url }) if (page) { const result = await updatePage(page.id, { archivedAt: null }, ctx) console.log('updated page from email', result) @@ -82,7 +83,6 @@ export const saveEmail = async ( return undefined } - console.log('created new page from email', pageId) articleToSave.id = pageId return articleToSave diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index 039082d34..df879df1c 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -22,7 +22,8 @@ interface NewsletterMessage { // Returns true if the link was created successfully. Can still fail to // send the push but that is ok and we wont retry in that case. export const saveNewsletterEmail = async ( - data: NewsletterMessage + data: NewsletterMessage, + ctx?: SaveContext ): Promise => { // get user from newsletter email const newsletterEmail = await getNewsletterEmail(data.email) @@ -43,7 +44,7 @@ export const saveNewsletterEmail = async ( }, }) - const ctx: SaveContext = { + const saveCtx = ctx || { pubsub: createPubSubClient(), uid: newsletterEmail.user.id, } @@ -55,14 +56,14 @@ export const saveNewsletterEmail = async ( author: data.author, } - const page = await saveEmail(ctx, input) + const page = await saveEmail(saveCtx, input) if (!page) { console.log('newsletter not created:', input) return false } // add newsletters label to page - const result = await addLabelToPage(ctx, page.id, { + const result = await addLabelToPage(saveCtx, page.id, { name: 'Newsletter', color: '#07D2D1', }) diff --git a/packages/api/src/services/user_device_tokens.ts b/packages/api/src/services/user_device_tokens.ts index 259da1708..66942d380 100644 --- a/packages/api/src/services/user_device_tokens.ts +++ b/packages/api/src/services/user_device_tokens.ts @@ -1,34 +1,36 @@ -import { getManager, getRepository } from 'typeorm' import { UserDeviceToken } from '../entity/user_device_tokens' import { User } from '../entity/user' import { SetDeviceTokenErrorCode } from '../generated/graphql' -import { setClaims } from '../entity/utils' +import { getRepository, setClaims } from '../entity/utils' import { analytics } from '../utils/analytics' import { env } from '../env' +import { AppDataSource } from '../server' export const getDeviceToken = async ( id: string -): Promise => { - return getRepository(UserDeviceToken).findOne(id) +): Promise => { + return getRepository(UserDeviceToken).findOneBy({ id }) } export const getDeviceTokenByToken = async ( token: string -): Promise => { - return getRepository(UserDeviceToken).findOne({ token }) +): Promise => { + return getRepository(UserDeviceToken).findOneBy({ token }) } export const getDeviceTokensByUserId = async ( userId: string ): Promise => { - return getRepository(UserDeviceToken).find({ where: { user: userId } }) + return getRepository(UserDeviceToken).find({ + where: { user: { id: userId } }, + }) } export const createDeviceToken = async ( userId: string, token: string ): Promise => { - const user = await getRepository(User).findOne(userId) + const user = await getRepository(User).findOneBy({ id: userId }) if (!user) { return Promise.reject({ errorCode: SetDeviceTokenErrorCode.Unauthorized, @@ -43,19 +45,17 @@ export const createDeviceToken = async ( }, }) - return getRepository(UserDeviceToken) - .create({ - token: token, - user: user, - }) - .save() + return getRepository(UserDeviceToken).save({ + token: token, + user: user, + }) } export const deleteDeviceToken = async ( id: string, userId: string ): Promise => { - const user = await getRepository(User).findOne(userId) + const user = await getRepository(User).findOneBy({ id: userId }) if (!user) { return Promise.reject({ errorCode: SetDeviceTokenErrorCode.Unauthorized, @@ -70,7 +70,7 @@ export const deleteDeviceToken = async ( }, }) - return getManager().transaction(async (t) => { + return AppDataSource.transaction(async (t) => { await setClaims(t, userId) const result = await t.getRepository(UserDeviceToken).delete(id) diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts index b92af6ead..182248196 100644 --- a/packages/api/test/db.ts +++ b/packages/api/test/db.ts @@ -1,13 +1,5 @@ -import { - createConnection, - getConnection, - getManager, - getRepository, -} from 'typeorm' -import { SnakeNamingStrategy } from 'typeorm-naming-strategies' import Postgrator from 'postgrator' import { User } from '../src/entity/user' -import { createUser } from '../src/services/create_user' import { Profile } from '../src/entity/profile' import { Page } from '../src/entity/page' import { Link } from '../src/entity/link' @@ -15,6 +7,10 @@ import { Reminder } from '../src/entity/reminder' import { NewsletterEmail } from '../src/entity/newsletter_email' import { UserDeviceToken } from '../src/entity/user_device_tokens' import { Label } from '../src/entity/label' +import { AppDataSource } from '../src/server' +import { getRepository } from '../src/entity/utils' +import { createUser } from '../src/services/create_user' +import { SnakeNamingStrategy } from 'typeorm-naming-strategies' const runMigrations = async () => { const migrationDirectory = __dirname + '/../../db/migrations' @@ -43,8 +39,10 @@ const runMigrations = async () => { } } -const createEntityConnection = async (): Promise => { - await createConnection({ +export const createTestConnection = async (): Promise => { + await runMigrations() + + AppDataSource.setOptions({ type: 'postgres', host: process.env.PG_HOST, port: Number(process.env.PG_PORT), @@ -57,21 +55,11 @@ const createEntityConnection = async (): Promise => { subscribers: [__dirname + '/../src/events/**/*{.js,.ts}'], namingStrategy: new SnakeNamingStrategy(), }) -} - -export const createTestConnection = async (): Promise => { - try { - getConnection() - // eslint-disable-next-line no-empty - } catch (error) {} - - await runMigrations() - await createEntityConnection() + await AppDataSource.initialize() } export const deleteTestUser = async (name: string) => { - await getConnection() - .createQueryBuilder() + await AppDataSource.createQueryBuilder() .delete() .from(User) .where({ email: `${name}@fake.com` }) @@ -93,68 +81,58 @@ export const createTestUser = async ( inviteCode: invite, password: password, }) + return newUser } export const createUserWithoutProfile = async (name: string): Promise => { - return getManager() - .getRepository(User) - .create({ - source: 'GOOGLE', - sourceUserId: 'fake-user-id-' + name, - email: `${name}@fake.com`, - name: name, - }) - .save() + return getRepository(User).save({ + source: 'GOOGLE', + sourceUserId: 'fake-user-id-' + name, + email: `${name}@fake.com`, + name: name, + }) } -export const getProfile = async (user: User): Promise => { - return Profile.findOne({ where: { user: user } }) +export const getProfile = async (user: User): Promise => { + return getRepository(Profile).findOneBy({ user: { id: user.id } }) } export const createTestPage = async (): Promise => { - return getRepository(Page) - .create({ - originalHtml: 'html', - content: 'Test content', - description: 'Test description', - title: 'Test title', - author: 'Test author', - url: 'Test url', - hash: 'Test hash', - }) - .save() + return getRepository(Page).save({ + originalHtml: 'html', + content: 'Test content', + description: 'Test description', + title: 'Test title', + author: 'Test author', + url: 'Test url', + hash: 'Test hash', + }) } export const createTestLink = async (user: User, page: Page): Promise => { - return getRepository(Link) - .create({ - user: user, - page: page, - slug: 'Test slug', - articleUrl: 'Test url', - articleHash: 'Test hash', - }) - .save() + return getRepository(Link).save({ + user: user, + page: page, + slug: 'Test slug', + articleUrl: 'Test url', + articleHash: 'Test hash', + }) } export const createTestReminder = async ( user: User, link?: string ): Promise => { - return getRepository(Reminder) - .create({ - user: user, - link: link, - remindAt: new Date(), - }) - .save() + return getRepository(Reminder).save({ + user: user, + link: link, + remindAt: new Date(), + }) } -export const getReminder = async ( - id: string -): Promise => { - return getRepository(Reminder).findOne(id) +export const getReminder = async (id: string): Promise => { + return getRepository(Reminder).findOneBy({ id }) } export const createTestNewsletterEmail = async ( @@ -162,44 +140,40 @@ export const createTestNewsletterEmail = async ( emailAddress?: string, confirmationCode?: string ): Promise => { - return getRepository(NewsletterEmail) - .create({ - user: user, - address: emailAddress, - confirmationCode: confirmationCode, - }) - .save() + return getRepository(NewsletterEmail).save({ + user: user, + address: emailAddress, + confirmationCode: confirmationCode, + }) } export const getNewsletterEmail = async ( id: string -): Promise => { - return getRepository(NewsletterEmail).findOne(id) +): Promise => { + return getRepository(NewsletterEmail).findOneBy({ id }) } export const createTestDeviceToken = async ( user: User ): Promise => { - return getRepository(UserDeviceToken) - .create({ - user: user, - token: 'Test token', - }) - .save() + return getRepository(UserDeviceToken).save({ + user: user, + token: 'Test token', + }) } export const getDeviceToken = async ( id: string -): Promise => { - return getRepository(UserDeviceToken).findOne(id) +): Promise => { + return getRepository(UserDeviceToken).findOneBy({ id }) } -export const getUser = async (id: string): Promise => { - return getRepository(User).findOne(id) +export const getUser = async (id: string): Promise => { + return getRepository(User).findOneBy({ id }) } -export const getLink = async (id: string): Promise => { - return getRepository(Link).findOne(id) +export const getLink = async (id: string): Promise => { + return getRepository(Link).findOneBy({ id }) } export const createTestLabel = async ( @@ -207,11 +181,9 @@ export const createTestLabel = async ( name: string, color: string ): Promise